diff --git a/src/App.tsx b/src/App.tsx index afa094b..fe2f1dd 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -36,6 +36,7 @@ import { FeatureLayout } from './layouts/FeatureLayout'; import { DashboardPage } from './pages/Dashboard'; import { SettingsPage } from './pages/Settings'; import { GDPRPage } from './pages/GDPR'; +import StorePage from './pages/Store'; import { FeatureViewPage } from './pages/FeatureView'; import { AccessManagementHub, AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage, AdminAutomationEventsPage, AdminLogsPage, AdminMandateWizardPage, AdminInvitationWizardPage } from './pages/admin'; import { PlaygroundPage, WorkflowsPage, AutomationsPage } from './pages/workflows'; @@ -94,6 +95,7 @@ function App() { } /> {/* System-Seiten (ohne Instanz-Kontext) */} + } /> } /> } /> diff --git a/src/api/roleApi.ts b/src/api/roleApi.ts index 38885b7..674aa57 100644 --- a/src/api/roleApi.ts +++ b/src/api/roleApi.ts @@ -92,25 +92,6 @@ export async function fetchRoleById( } } -/** - * Fetch role options - * Endpoint: GET /api/rbac/roles/options - */ -export async function fetchRoleOptions( - request: ApiRequestFunction -): Promise { - try { - const data = await request({ - url: '/api/rbac/roles/options', - method: 'get' - }); - return data || null; - } catch (error: any) { - console.error('Error fetching role options:', error); - return null; - } -} - /** * Create a new role * Endpoint: POST /api/rbac/roles diff --git a/src/api/storeApi.ts b/src/api/storeApi.ts new file mode 100644 index 0000000..c4a3b7d --- /dev/null +++ b/src/api/storeApi.ts @@ -0,0 +1,47 @@ +/** + * Store API + * + * API layer for the Feature Store. + * Manages feature activation/deactivation in the root mandate's shared instances. + */ + +import api from '../api'; + +export interface StoreFeature { + featureCode: string; + label: Record; + icon: string; + description: Record; + isActive: boolean; + canActivate: boolean; + instanceId: string | null; +} + +export interface StoreActivateResponse { + featureCode: string; + instanceId: string; + featureAccessId: string; + roleId: string | null; + activated: boolean; +} + +export interface StoreDeactivateResponse { + featureCode: string; + instanceId: string; + deactivated: boolean; +} + +export async function fetchStoreFeatures(): Promise { + const response = await api.get('/api/store/features'); + return response.data; +} + +export async function activateStoreFeature(featureCode: string): Promise { + const response = await api.post('/api/store/activate', { featureCode }); + return response.data; +} + +export async function deactivateStoreFeature(featureCode: string): Promise { + const response = await api.post('/api/store/deactivate', { featureCode }); + return response.data; +} diff --git a/src/config/pageRegistry.tsx b/src/config/pageRegistry.tsx index 31373e2..e4544d6 100644 --- a/src/config/pageRegistry.tsx +++ b/src/config/pageRegistry.tsx @@ -21,7 +21,7 @@ import { FaLightbulb, FaRegFileAlt, FaLink, FaComments, FaListAlt, FaCogs, FaChartLine, FaFileAlt, FaUserShield, FaDatabase, FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock, - FaHeadset, FaVideo, FaHatWizard, + FaHeadset, FaVideo, FaHatWizard, FaStore, } from 'react-icons/fa'; // ============================================================================= @@ -36,6 +36,7 @@ export const PAGE_ICONS: Record = { // System pages 'page.system.home': , 'page.system.settings': , + 'page.system.store': , 'page.system.gdpr': , // Basedata pages (system-level) diff --git a/src/hooks/useMandateRoles.ts b/src/hooks/useMandateRoles.ts index ba425cb..4feb2a7 100644 --- a/src/hooks/useMandateRoles.ts +++ b/src/hooks/useMandateRoles.ts @@ -240,25 +240,6 @@ export function useMandateRoles() { } }, []); - /** - * Get role options (for dropdowns) - */ - const fetchRoleOptions = useCallback(async (): Promise> => { - try { - const response = await api.get('/api/rbac/roles/options'); - if (Array.isArray(response.data)) { - return response.data.map((r: any) => ({ - value: r.id || r.value, - label: r.roleLabel || r.label || r.id - })); - } - return []; - } catch (err: any) { - console.error('Error fetching role options:', err); - return []; - } - }, []); - /** * Get users with a specific role */ @@ -305,7 +286,6 @@ export function useMandateRoles() { createRole, updateRole, deleteRole, - fetchRoleOptions, getUsersWithRole, getMandateRoles, getFeatureRoles, diff --git a/src/hooks/useNavigation.ts b/src/hooks/useNavigation.ts index 7450bf6..c72d8da 100644 --- a/src/hooks/useNavigation.ts +++ b/src/hooks/useNavigation.ts @@ -168,6 +168,14 @@ export function useNavigation(language: string = 'de'): UseNavigationReturn { fetchNavigation(); }, [fetchNavigation]); + useEffect(() => { + const onFeaturesChanged = () => { + fetchNavigation(); + }; + window.addEventListener('features-changed', onFeaturesChanged); + return () => window.removeEventListener('features-changed', onFeaturesChanged); + }, [fetchNavigation]); + // Derive static and dynamic blocks const staticBlocks = blocks.filter(isStaticBlock); const dynamicBlock = blocks.find(isDynamicBlock) || null; diff --git a/src/hooks/useStore.ts b/src/hooks/useStore.ts new file mode 100644 index 0000000..f13dbc3 --- /dev/null +++ b/src/hooks/useStore.ts @@ -0,0 +1,90 @@ +/** + * useStore Hook + * + * Manages feature store interactions: loading catalog, activating/deactivating features. + * After each mutation, refreshes featureStore and dispatches 'features-changed' event + * so navigation and other components update in real-time. + */ + +import { useState, useCallback, useEffect } from 'react'; +import { + fetchStoreFeatures, + activateStoreFeature, + deactivateStoreFeature, + type StoreFeature, +} from '../api/storeApi'; +import { useFeatureStore } from '../stores/featureStore'; + +interface UseStoreReturn { + features: StoreFeature[]; + loading: boolean; + actionLoading: string | null; + error: string | null; + loadStore: () => Promise; + activate: (featureCode: string) => Promise; + deactivate: (featureCode: string) => Promise; +} + +export function useStore(): UseStoreReturn { + const [features, setFeatures] = useState([]); + const [loading, setLoading] = useState(true); + const [actionLoading, setActionLoading] = useState(null); + const [error, setError] = useState(null); + const featureStore = useFeatureStore(); + + const loadStore = useCallback(async () => { + setLoading(true); + setError(null); + try { + const data = await fetchStoreFeatures(); + setFeatures(data); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Failed to load store'; + setError(msg); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadStore(); + }, [loadStore]); + + const _refreshAfterAction = useCallback(async () => { + await featureStore.loadFeatures(); + window.dispatchEvent(new CustomEvent('features-changed')); + await loadStore(); + }, [featureStore, loadStore]); + + const activate = useCallback(async (featureCode: string) => { + setActionLoading(featureCode); + setError(null); + try { + await activateStoreFeature(featureCode); + await _refreshAfterAction(); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Activation failed'; + setError(msg); + } finally { + setActionLoading(null); + } + }, [_refreshAfterAction]); + + const deactivate = useCallback(async (featureCode: string) => { + setActionLoading(featureCode); + setError(null); + try { + await deactivateStoreFeature(featureCode); + await _refreshAfterAction(); + } catch (err: unknown) { + const msg = err instanceof Error ? err.message : 'Deactivation failed'; + setError(msg); + } finally { + setActionLoading(null); + } + }, [_refreshAfterAction]); + + return { features, loading, actionLoading, error, loadStore, activate, deactivate }; +} + +export default useStore; diff --git a/src/hooks/useUserMandates.ts b/src/hooks/useUserMandates.ts index cce62ca..b73838d 100644 --- a/src/hooks/useUserMandates.ts +++ b/src/hooks/useUserMandates.ts @@ -236,8 +236,11 @@ export function useUserMandates() { */ const fetchRoles = useCallback(async (mandateId?: string): Promise => { try { - // Fetch roles server-side filtered by mandate (or templates if no mandateId) - const params = mandateId ? { mandateId } : {}; + const params: Record = {}; + if (mandateId) { + params.mandateId = mandateId; + params.scopeFilter = 'mandate'; + } const response = await api.get('/api/rbac/roles', { params }); let roles: Role[] = []; if (response.data?.items && Array.isArray(response.data.items)) { diff --git a/src/pages/Store.module.css b/src/pages/Store.module.css new file mode 100644 index 0000000..c4a67da --- /dev/null +++ b/src/pages/Store.module.css @@ -0,0 +1,262 @@ +/** + * Store Page Styles + */ + +.store { + padding: 2rem; + max-width: 1000px; + margin: 0 auto; +} + +/* Header */ +.header { + margin-bottom: 2rem; +} + +.header h1 { + margin: 0; + font-size: 1.75rem; + font-weight: 700; + color: var(--text-primary, #1a1a1a); +} + +.subtitle { + margin: 0.5rem 0 0; + color: var(--text-secondary, #666); + font-size: 0.9375rem; +} + +/* Grid */ +.grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1.25rem; +} + +/* Card */ +.card { + background: var(--surface-color, #ffffff); + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 12px; + padding: 1.5rem; + display: flex; + flex-direction: column; + gap: 1rem; + transition: box-shadow 0.2s ease, border-color 0.2s ease; +} + +.card:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.08); + border-color: var(--border-color, #ccc); +} + +.cardActive { + border-color: var(--primary-color, #2563eb); + background: var(--primary-bg, rgba(37, 99, 235, 0.04)); +} + +.cardHeader { + display: flex; + align-items: center; + gap: 0.75rem; +} + +.cardIcon { + font-size: 1.75rem; + color: var(--primary-color, #2563eb); + flex-shrink: 0; +} + +.cardTitle { + margin: 0; + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary, #1a1a1a); +} + +.cardBody { + flex: 1; +} + +.cardDescription { + margin: 0; + font-size: 0.875rem; + color: var(--text-secondary, #666); + line-height: 1.5; +} + +/* Status Badge */ +.statusBadge { + display: inline-flex; + align-items: center; + gap: 0.375rem; + font-size: 0.75rem; + font-weight: 500; + padding: 0.25rem 0.625rem; + border-radius: 999px; +} + +.statusActive { + background: var(--success-bg, #ecfdf5); + color: var(--success-color, #059669); +} + +.statusInactive { + background: var(--surface-color, #f5f5f5); + color: var(--text-secondary, #666); +} + +.statusDot { + width: 6px; + height: 6px; + border-radius: 50%; + background: currentColor; +} + +/* Actions */ +.cardActions { + padding-top: 0.5rem; + border-top: 1px solid var(--border-color, #e0e0e0); +} + +.activateButton { + width: 100%; + padding: 0.625rem 1rem; + border: none; + border-radius: 8px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + background: var(--primary-color, #2563eb); + color: #ffffff; +} + +.activateButton:hover:not(:disabled) { + background: var(--primary-hover, #1d4ed8); +} + +.deactivateButton { + width: 100%; + padding: 0.625rem 1rem; + border: 1px solid var(--border-color, #e0e0e0); + border-radius: 8px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; + background: transparent; + color: var(--text-secondary, #666); +} + +.deactivateButton:hover:not(:disabled) { + border-color: var(--error-color, #dc2626); + color: var(--error-color, #dc2626); + background: var(--error-bg, #fef2f2); +} + +.activateButton:disabled, +.deactivateButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Loading */ +.loading { + display: flex; + justify-content: center; + align-items: center; + min-height: 200px; + color: var(--text-secondary, #666); + font-size: 0.9375rem; +} + +/* Error */ +.error { + background: var(--error-bg, #fef2f2); + border: 1px solid var(--error-border, #fecaca); + color: var(--error-color, #dc2626); + padding: 1rem 1.25rem; + border-radius: 8px; + font-size: 0.875rem; + margin-bottom: 1.5rem; +} + +/* Empty */ +.empty { + text-align: center; + padding: 3rem 1rem; + color: var(--text-secondary, #666); + font-size: 0.9375rem; +} + +/* Dark Theme */ +:global(.dark-theme) .header h1 { + color: var(--text-primary-dark, #ffffff); +} + +:global(.dark-theme) .subtitle { + color: var(--text-secondary-dark, #aaa); +} + +:global(.dark-theme) .card { + background: var(--surface-dark, #1a1a1a); + border-color: var(--border-dark, #333); +} + +:global(.dark-theme) .card:hover { + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3); + border-color: var(--border-dark, #555); +} + +:global(.dark-theme) .cardActive { + border-color: var(--primary-color, #2563eb); + background: rgba(37, 99, 235, 0.08); +} + +:global(.dark-theme) .cardTitle { + color: var(--text-primary-dark, #ffffff); +} + +:global(.dark-theme) .cardDescription { + color: var(--text-secondary-dark, #aaa); +} + +:global(.dark-theme) .statusActive { + background: rgba(5, 150, 105, 0.15); + color: var(--success-color, #34d399); +} + +:global(.dark-theme) .statusInactive { + background: var(--surface-dark, #2a2a2a); + color: var(--text-secondary-dark, #aaa); +} + +:global(.dark-theme) .cardActions { + border-top-color: var(--border-dark, #333); +} + +:global(.dark-theme) .deactivateButton { + border-color: var(--border-dark, #444); + color: var(--text-secondary-dark, #aaa); +} + +:global(.dark-theme) .deactivateButton:hover:not(:disabled) { + border-color: var(--error-color-dark, #f87171); + color: var(--error-color-dark, #f87171); + background: rgba(248, 113, 113, 0.1); +} + +:global(.dark-theme) .error { + background: var(--error-bg-dark, #450a0a); + border-color: var(--error-border-dark, #991b1b); + color: var(--error-color-dark, #f87171); +} + +:global(.dark-theme) .empty { + color: var(--text-secondary-dark, #aaa); +} + +:global(.dark-theme) .loading { + color: var(--text-secondary-dark, #aaa); +} diff --git a/src/pages/Store.tsx b/src/pages/Store.tsx new file mode 100644 index 0000000..0f023e7 --- /dev/null +++ b/src/pages/Store.tsx @@ -0,0 +1,166 @@ +/** + * Store Page + * + * Feature Store where users can self-activate features in the root mandate. + * Uses the Shared Instance Pattern -- each feature has one shared instance, + * and users get their own FeatureAccess + user-role upon activation. + */ + +import React from 'react'; +import { FaCogs, FaComments, FaHeadset } from 'react-icons/fa'; +import { useLanguage } from '../providers/language/LanguageContext'; +import { useStore } from '../hooks/useStore'; +import type { StoreFeature } from '../api/storeApi'; +import styles from './Store.module.css'; + +const FEATURE_ICONS: Record = { + automation: , + chatplayground: , + teamsbot: , +}; + +const FEATURE_DESCRIPTIONS: Record> = { + automation: { + de: 'Erstelle und verwalte Automatisierungen, um wiederkehrende Aufgaben effizient zu erledigen.', + en: 'Create and manage automations to handle recurring tasks efficiently.', + fr: 'Creer et gerer des automatisations pour traiter efficacement les taches recurrentes.', + }, + chatplayground: { + de: 'Teste und experimentiere mit AI-Chat-Modellen in einer interaktiven Umgebung.', + en: 'Test and experiment with AI chat models in an interactive environment.', + fr: 'Testez et experimentez avec des modeles de chat IA dans un environnement interactif.', + }, + teamsbot: { + de: 'Integriere einen AI-Bot in deine Microsoft Teams Meetings und Channels.', + en: 'Integrate an AI bot into your Microsoft Teams meetings and channels.', + fr: 'Integrez un bot IA dans vos reunions et canaux Microsoft Teams.', + }, +}; + +function _getLabel(labels: Record, lang: string): string { + return labels[lang] || labels['en'] || labels['de'] || Object.values(labels)[0] || ''; +} + +function _getDescription(featureCode: string, lang: string): string { + const desc = FEATURE_DESCRIPTIONS[featureCode]; + if (!desc) return ''; + return desc[lang] || desc['en'] || desc['de'] || ''; +} + +interface FeatureCardProps { + feature: StoreFeature; + language: string; + actionLoading: string | null; + onActivate: (code: string) => void; + onDeactivate: (code: string) => void; +} + +const FeatureCard: React.FC = ({ + feature, + language, + actionLoading, + onActivate, + onDeactivate, +}) => { + const isProcessing = actionLoading === feature.featureCode; + const icon = FEATURE_ICONS[feature.featureCode]; + + return ( +
+
+ {icon && {icon}} +

+ {_getLabel(feature.label, language)} +

+
+ +
+

+ {_getDescription(feature.featureCode, language)} +

+
+ +
+ + + {feature.isActive + ? (language === 'de' ? 'Aktiv' : language === 'fr' ? 'Actif' : 'Active') + : (language === 'de' ? 'Verfuegbar' : language === 'fr' ? 'Disponible' : 'Available')} + +
+ +
+ {feature.isActive ? ( + + ) : ( + + )} +
+
+ ); +}; + +const StorePage: React.FC = () => { + const { currentLanguage } = useLanguage(); + const { features, loading, actionLoading, error, activate, deactivate } = useStore(); + + return ( +
+
+

{currentLanguage === 'de' ? 'Feature Store' : currentLanguage === 'fr' ? 'Feature Store' : 'Feature Store'}

+

+ {currentLanguage === 'de' + ? 'Aktiviere Features fuer dein Konto. Deine Daten sind isoliert und nur fuer dich sichtbar.' + : currentLanguage === 'fr' + ? 'Activez des fonctionnalites pour votre compte. Vos donnees sont isolees et visibles uniquement par vous.' + : 'Activate features for your account. Your data is isolated and only visible to you.'} +

+
+ + {error &&
{error}
} + + {loading ? ( +
+ {currentLanguage === 'de' ? 'Lade Features...' : 'Loading features...'} +
+ ) : features.length === 0 ? ( +
+ {currentLanguage === 'de' + ? 'Keine Features im Store verfuegbar.' + : 'No features available in the store.'} +
+ ) : ( +
+ {features.map((feature) => ( + + ))} +
+ )} +
+ ); +}; + +export default StorePage;