commit
9a59cdac86
10 changed files with 582 additions and 42 deletions
|
|
@ -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 />} />
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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
47
src/api/storeApi.ts
Normal 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;
|
||||||
|
}
|
||||||
|
|
@ -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)
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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
90
src/hooks/useStore.ts
Normal 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;
|
||||||
|
|
@ -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
262
src/pages/Store.module.css
Normal 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
166
src/pages/Store.tsx
Normal 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;
|
||||||
Loading…
Reference in a new issue