Merge pull request #10 from valueonag/feat/user-store

feat/user-store
This commit is contained in:
Patrick Motsch 2026-02-23 17:15:59 +01:00 committed by GitHub
commit 9a59cdac86
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
10 changed files with 582 additions and 42 deletions

View file

@ -36,6 +36,7 @@ import { FeatureLayout } from './layouts/FeatureLayout';
import { DashboardPage } from './pages/Dashboard'; import { DashboardPage } from './pages/Dashboard';
import { SettingsPage } from './pages/Settings'; import { SettingsPage } from './pages/Settings';
import { GDPRPage } from './pages/GDPR'; import { GDPRPage } from './pages/GDPR';
import StorePage from './pages/Store';
import { FeatureViewPage } from './pages/FeatureView'; 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 { 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'; import { PlaygroundPage, WorkflowsPage, AutomationsPage } from './pages/workflows';
@ -94,6 +95,7 @@ function App() {
<Route index element={<DashboardPage />} /> <Route index element={<DashboardPage />} />
{/* System-Seiten (ohne Instanz-Kontext) */} {/* System-Seiten (ohne Instanz-Kontext) */}
<Route path="store" element={<StorePage />} />
<Route path="settings" element={<SettingsPage />} /> <Route path="settings" element={<SettingsPage />} />
<Route path="gdpr" element={<GDPRPage />} /> <Route path="gdpr" element={<GDPRPage />} />

View file

@ -92,25 +92,6 @@ export async function fetchRoleById(
} }
} }
/**
* Fetch role options
* Endpoint: GET /api/rbac/roles/options
*/
export async function fetchRoleOptions(
request: ApiRequestFunction
): Promise<any> {
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 * Create a new role
* Endpoint: POST /api/rbac/roles * Endpoint: POST /api/rbac/roles

47
src/api/storeApi.ts Normal file
View file

@ -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<string, string>;
icon: string;
description: Record<string, string>;
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<StoreFeature[]> {
const response = await api.get<StoreFeature[]>('/api/store/features');
return response.data;
}
export async function activateStoreFeature(featureCode: string): Promise<StoreActivateResponse> {
const response = await api.post<StoreActivateResponse>('/api/store/activate', { featureCode });
return response.data;
}
export async function deactivateStoreFeature(featureCode: string): Promise<StoreDeactivateResponse> {
const response = await api.post<StoreDeactivateResponse>('/api/store/deactivate', { featureCode });
return response.data;
}

View file

@ -21,7 +21,7 @@ import {
FaLightbulb, FaRegFileAlt, FaLink, FaComments, FaLightbulb, FaRegFileAlt, FaLink, FaComments,
FaListAlt, FaCogs, FaChartLine, FaFileAlt, FaUserShield, FaDatabase, FaListAlt, FaCogs, FaChartLine, FaFileAlt, FaUserShield, FaDatabase,
FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock, FaProjectDiagram, FaMapMarkedAlt, FaWallet, FaMoneyBillAlt, FaClock,
FaHeadset, FaVideo, FaHatWizard, FaHeadset, FaVideo, FaHatWizard, FaStore,
} from 'react-icons/fa'; } from 'react-icons/fa';
// ============================================================================= // =============================================================================
@ -36,6 +36,7 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
// System pages // System pages
'page.system.home': <FaHome />, 'page.system.home': <FaHome />,
'page.system.settings': <FaCog />, 'page.system.settings': <FaCog />,
'page.system.store': <FaStore />,
'page.system.gdpr': <FaShieldAlt />, 'page.system.gdpr': <FaShieldAlt />,
// Basedata pages (system-level) // Basedata pages (system-level)

View file

@ -240,25 +240,6 @@ export function useMandateRoles() {
} }
}, []); }, []);
/**
* Get role options (for dropdowns)
*/
const fetchRoleOptions = useCallback(async (): Promise<Array<{ value: string; label: string }>> => {
try {
const response = await api.get('/api/rbac/roles/options');
if (Array.isArray(response.data)) {
return response.data.map((r: any) => ({
value: r.id || r.value,
label: r.roleLabel || r.label || r.id
}));
}
return [];
} catch (err: any) {
console.error('Error fetching role options:', err);
return [];
}
}, []);
/** /**
* Get users with a specific role * Get users with a specific role
*/ */
@ -305,7 +286,6 @@ export function useMandateRoles() {
createRole, createRole,
updateRole, updateRole,
deleteRole, deleteRole,
fetchRoleOptions,
getUsersWithRole, getUsersWithRole,
getMandateRoles, getMandateRoles,
getFeatureRoles, getFeatureRoles,

View file

@ -168,6 +168,14 @@ export function useNavigation(language: string = 'de'): UseNavigationReturn {
fetchNavigation(); fetchNavigation();
}, [fetchNavigation]); }, [fetchNavigation]);
useEffect(() => {
const onFeaturesChanged = () => {
fetchNavigation();
};
window.addEventListener('features-changed', onFeaturesChanged);
return () => window.removeEventListener('features-changed', onFeaturesChanged);
}, [fetchNavigation]);
// Derive static and dynamic blocks // Derive static and dynamic blocks
const staticBlocks = blocks.filter(isStaticBlock); const staticBlocks = blocks.filter(isStaticBlock);
const dynamicBlock = blocks.find(isDynamicBlock) || null; const dynamicBlock = blocks.find(isDynamicBlock) || null;

90
src/hooks/useStore.ts Normal file
View file

@ -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<void>;
activate: (featureCode: string) => Promise<void>;
deactivate: (featureCode: string) => Promise<void>;
}
export function useStore(): UseStoreReturn {
const [features, setFeatures] = useState<StoreFeature[]>([]);
const [loading, setLoading] = useState(true);
const [actionLoading, setActionLoading] = useState<string | null>(null);
const [error, setError] = useState<string | null>(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;

View file

@ -236,8 +236,11 @@ export function useUserMandates() {
*/ */
const fetchRoles = useCallback(async (mandateId?: string): Promise<Role[]> => { const fetchRoles = useCallback(async (mandateId?: string): Promise<Role[]> => {
try { try {
// Fetch roles server-side filtered by mandate (or templates if no mandateId) const params: Record<string, string> = {};
const params = mandateId ? { mandateId } : {}; if (mandateId) {
params.mandateId = mandateId;
params.scopeFilter = 'mandate';
}
const response = await api.get('/api/rbac/roles', { params }); const response = await api.get('/api/rbac/roles', { params });
let roles: Role[] = []; let roles: Role[] = [];
if (response.data?.items && Array.isArray(response.data.items)) { if (response.data?.items && Array.isArray(response.data.items)) {

262
src/pages/Store.module.css Normal file
View file

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

166
src/pages/Store.tsx Normal file
View file

@ -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<string, React.ReactNode> = {
automation: <FaCogs />,
chatplayground: <FaComments />,
teamsbot: <FaHeadset />,
};
const FEATURE_DESCRIPTIONS: Record<string, Record<string, string>> = {
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<string, string>, 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<FeatureCardProps> = ({
feature,
language,
actionLoading,
onActivate,
onDeactivate,
}) => {
const isProcessing = actionLoading === feature.featureCode;
const icon = FEATURE_ICONS[feature.featureCode];
return (
<div className={`${styles.card} ${feature.isActive ? styles.cardActive : ''}`}>
<div className={styles.cardHeader}>
{icon && <span className={styles.cardIcon}>{icon}</span>}
<h3 className={styles.cardTitle}>
{_getLabel(feature.label, language)}
</h3>
</div>
<div className={styles.cardBody}>
<p className={styles.cardDescription}>
{_getDescription(feature.featureCode, language)}
</p>
</div>
<div>
<span className={`${styles.statusBadge} ${feature.isActive ? styles.statusActive : styles.statusInactive}`}>
<span className={styles.statusDot} />
{feature.isActive
? (language === 'de' ? 'Aktiv' : language === 'fr' ? 'Actif' : 'Active')
: (language === 'de' ? 'Verfuegbar' : language === 'fr' ? 'Disponible' : 'Available')}
</span>
</div>
<div className={styles.cardActions}>
{feature.isActive ? (
<button
className={styles.deactivateButton}
onClick={() => onDeactivate(feature.featureCode)}
disabled={isProcessing}
>
{isProcessing
? (language === 'de' ? 'Wird deaktiviert...' : 'Deactivating...')
: (language === 'de' ? 'Deaktivieren' : language === 'fr' ? 'Desactiver' : 'Deactivate')}
</button>
) : (
<button
className={styles.activateButton}
onClick={() => onActivate(feature.featureCode)}
disabled={isProcessing || !feature.canActivate}
>
{isProcessing
? (language === 'de' ? 'Wird aktiviert...' : 'Activating...')
: (language === 'de' ? 'Aktivieren' : language === 'fr' ? 'Activer' : 'Activate')}
</button>
)}
</div>
</div>
);
};
const StorePage: React.FC = () => {
const { currentLanguage } = useLanguage();
const { features, loading, actionLoading, error, activate, deactivate } = useStore();
return (
<div className={styles.store}>
<div className={styles.header}>
<h1>{currentLanguage === 'de' ? 'Feature Store' : currentLanguage === 'fr' ? 'Feature Store' : 'Feature Store'}</h1>
<p className={styles.subtitle}>
{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.'}
</p>
</div>
{error && <div className={styles.error}>{error}</div>}
{loading ? (
<div className={styles.loading}>
{currentLanguage === 'de' ? 'Lade Features...' : 'Loading features...'}
</div>
) : features.length === 0 ? (
<div className={styles.empty}>
{currentLanguage === 'de'
? 'Keine Features im Store verfuegbar.'
: 'No features available in the store.'}
</div>
) : (
<div className={styles.grid}>
{features.map((feature) => (
<FeatureCard
key={feature.featureCode}
feature={feature}
language={currentLanguage}
actionLoading={actionLoading}
onActivate={activate}
onDeactivate={deactivate}
/>
))}
</div>
)}
</div>
);
};
export default StorePage;