From 7f07a55c911558104d9cf2c5b8430df7686be554 Mon Sep 17 00:00:00 2001 From: ValueOn AG Date: Wed, 21 Jan 2026 00:32:52 +0100 Subject: [PATCH] saas mandates core done --- src/App.tsx | 8 +- .../AccessRules/AccessLevelSelect.tsx | 59 ++ .../AccessRules/AccessRules.module.css | 535 +++++++++++++++ .../AccessRules/AccessRulesEditor.tsx | 626 ++++++++++++++++++ src/components/AccessRules/index.ts | 8 + .../FormGeneratorTable.module.css | 6 + .../FormGeneratorTable/FormGeneratorTable.tsx | 179 +++++ .../Navigation/MandateNavigation.tsx | 38 +- .../RbacExportImport.module.css | 507 ++++++++++++++ .../RbacExportImport/RbacExportImport.tsx | 459 +++++++++++++ src/components/RbacExportImport/index.ts | 5 + src/hooks/useAccessRules.ts | 248 +++++++ src/hooks/useFeatureAccess.ts | 249 +++++++ src/hooks/useInvitations.ts | 222 +++++++ src/hooks/useMandateRoles.ts | 242 +++++++ src/hooks/useMandates.ts | 4 +- src/hooks/useRbacExportImport.ts | 270 ++++++++ src/hooks/useRoles.ts | 4 +- src/hooks/useUserMandates.ts | 223 +++++++ src/pages/InvitePage.module.css | 316 +++++++++ src/pages/InvitePage.tsx | 363 ++++++++++ src/pages/Settings.module.css | 172 +++++ src/pages/Settings.tsx | 222 ++++++- src/pages/admin/Admin.module.css | 124 ++++ src/pages/admin/AdminFeatureAccessPage.tsx | 347 ++++++++++ src/pages/admin/AdminInvitationsPage.tsx | 457 +++++++++++++ src/pages/admin/AdminMandateRolesPage.tsx | 527 +++++++++++++++ src/pages/admin/AdminMandatesPage.tsx | 95 +-- src/pages/admin/AdminRolesPage.tsx | 106 ++- src/pages/admin/AdminUserMandatesPage.tsx | 442 +++++++++++++ src/pages/admin/AdminUsersPage.tsx | 126 ++-- src/pages/admin/index.ts | 4 + 32 files changed, 6992 insertions(+), 201 deletions(-) create mode 100644 src/components/AccessRules/AccessLevelSelect.tsx create mode 100644 src/components/AccessRules/AccessRules.module.css create mode 100644 src/components/AccessRules/AccessRulesEditor.tsx create mode 100644 src/components/AccessRules/index.ts create mode 100644 src/components/RbacExportImport/RbacExportImport.module.css create mode 100644 src/components/RbacExportImport/RbacExportImport.tsx create mode 100644 src/components/RbacExportImport/index.ts create mode 100644 src/hooks/useAccessRules.ts create mode 100644 src/hooks/useFeatureAccess.ts create mode 100644 src/hooks/useInvitations.ts create mode 100644 src/hooks/useMandateRoles.ts create mode 100644 src/hooks/useRbacExportImport.ts create mode 100644 src/hooks/useUserMandates.ts create mode 100644 src/pages/InvitePage.module.css create mode 100644 src/pages/InvitePage.tsx create mode 100644 src/pages/admin/AdminFeatureAccessPage.tsx create mode 100644 src/pages/admin/AdminInvitationsPage.tsx create mode 100644 src/pages/admin/AdminMandateRolesPage.tsx create mode 100644 src/pages/admin/AdminUserMandatesPage.tsx diff --git a/src/App.tsx b/src/App.tsx index ecf97b9..f3de0df 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -21,6 +21,7 @@ import Login from './pages/Login'; import Register from './pages/Register'; import PasswordResetRequest from './pages/PasswordResetRequest'; import Reset from './pages/Reset'; +import { InvitePage } from './pages/InvitePage'; // Providers import { AuthProvider } from './providers/auth/AuthProvider'; @@ -35,7 +36,7 @@ import { FeatureLayout } from './layouts/FeatureLayout'; import { DashboardPage } from './pages/Dashboard'; import { SettingsPage } from './pages/Settings'; import { FeatureViewPage } from './pages/FeatureView'; -import { AdminMandatesPage, AdminUsersPage, AdminRolesPage } from './pages/admin'; +import { AdminMandatesPage, AdminUsersPage, AdminRolesPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage } from './pages/admin'; function App() { // Load saved theme preference and set app name on app mount @@ -72,6 +73,7 @@ function App() { } /> } /> } /> + } /> {/* ================================================== */} {/* PROTECTED ROUTES - REQUIRE AUTHENTICATION */} @@ -119,6 +121,10 @@ function App() { } /> } /> } /> + } /> + } /> + } /> + } /> diff --git a/src/components/AccessRules/AccessLevelSelect.tsx b/src/components/AccessRules/AccessLevelSelect.tsx new file mode 100644 index 0000000..bafba16 --- /dev/null +++ b/src/components/AccessRules/AccessLevelSelect.tsx @@ -0,0 +1,59 @@ +/** + * AccessLevelSelect + * + * Dropdown component for selecting RBAC access levels (n/m/g/a). + */ + +import React from 'react'; +import { ACCESS_LEVEL_OPTIONS, type AccessLevel, getAccessLevelColor } from '../../hooks/useAccessRules'; +import styles from './AccessRules.module.css'; + +interface AccessLevelSelectProps { + value: AccessLevel | null; + onChange: (value: AccessLevel) => void; + disabled?: boolean; + label?: string; + showLabel?: boolean; + compact?: boolean; +} + +export const AccessLevelSelect: React.FC = ({ + value, + onChange, + disabled = false, + label, + showLabel = false, + compact = false, +}) => { + const currentColor = getAccessLevelColor(value); + + return ( +
+ {showLabel && label && ( + + )} + +
+ ); +}; + +export default AccessLevelSelect; diff --git a/src/components/AccessRules/AccessRules.module.css b/src/components/AccessRules/AccessRules.module.css new file mode 100644 index 0000000..2b36518 --- /dev/null +++ b/src/components/AccessRules/AccessRules.module.css @@ -0,0 +1,535 @@ +/* ============================================================================= + * AccessRules Components Styles + * ============================================================================= */ + +/* ============================================================================= + * Access Level Select + * ============================================================================= */ + +.accessLevelSelect { + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.accessLevelSelect.compact { + gap: 0; +} + +.accessLevelLabel { + font-size: 0.75rem; + color: var(--text-secondary); + font-weight: 500; +} + +.accessLevelDropdown { + padding: 0.375rem 0.5rem; + border: 2px solid var(--border-color); + border-radius: 4px; + background: var(--bg-primary); + font-size: 0.875rem; + font-weight: 600; + cursor: pointer; + min-width: 80px; + transition: border-color 0.2s, box-shadow 0.2s; +} + +.accessLevelDropdown:hover:not(:disabled) { + box-shadow: 0 0 0 2px var(--primary-color-light); +} + +.accessLevelDropdown:focus { + outline: none; + box-shadow: 0 0 0 2px var(--primary-color); +} + +.accessLevelDropdown:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* ============================================================================= + * Access Rules Editor + * ============================================================================= */ + +.accessRulesEditor { + display: flex; + flex-direction: column; + gap: 1rem; + padding: 1rem; + background: var(--bg-primary); + border-radius: 8px; + border: 1px solid var(--border-color); +} + +.editorHeader { + display: flex; + justify-content: space-between; + align-items: center; + padding-bottom: 0.75rem; + border-bottom: 1px solid var(--border-color); +} + +.editorTitle { + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); + margin: 0; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.templateBadge { + background: var(--info-color); + color: white; + font-size: 0.625rem; + padding: 0.125rem 0.375rem; + border-radius: 4px; + text-transform: uppercase; + font-weight: 700; +} + +.headerActions { + display: flex; + gap: 0.5rem; +} + +/* ============================================================================= + * Tabs + * ============================================================================= */ + +.tabsContainer { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.tabList { + display: flex; + gap: 0.25rem; + border-bottom: 2px solid var(--border-color); + padding-bottom: -2px; +} + +.tab { + display: flex; + align-items: center; + gap: 0.5rem; + padding: 0.625rem 1rem; + background: none; + border: none; + border-bottom: 2px solid transparent; + margin-bottom: -2px; + cursor: pointer; + font-size: 0.875rem; + font-weight: 500; + color: var(--text-secondary); + transition: all 0.2s; +} + +.tab:hover { + color: var(--text-primary); + background: var(--bg-secondary); +} + +.tab.active { + color: var(--primary-color); + border-bottom-color: var(--primary-color); +} + +.tabIcon { + font-size: 1rem; +} + +.tabBadge { + background: var(--bg-tertiary); + color: var(--text-secondary); + font-size: 0.75rem; + padding: 0.125rem 0.375rem; + border-radius: 10px; + min-width: 20px; + text-align: center; +} + +.tab.active .tabBadge { + background: var(--primary-color); + color: white; +} + +.tabContent { + min-height: 200px; +} + +/* ============================================================================= + * Rules Section + * ============================================================================= */ + +.rulesSection { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.sectionHeader { + display: flex; + justify-content: space-between; + align-items: center; +} + +.sectionTitle { + font-size: 0.875rem; + font-weight: 600; + color: var(--text-secondary); + text-transform: uppercase; + letter-spacing: 0.05em; +} + +.addButton { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.375rem 0.75rem; + background: var(--primary-color); + color: white; + border: none; + border-radius: 4px; + font-size: 0.8125rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.addButton:hover { + background: var(--primary-color-dark); +} + +.addButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* ============================================================================= + * Rule Card + * ============================================================================= */ + +.ruleCard { + display: flex; + flex-direction: column; + gap: 0.75rem; + padding: 0.875rem; + background: var(--bg-secondary); + border-radius: 6px; + border: 1px solid var(--border-color); +} + +.ruleHeader { + display: flex; + justify-content: space-between; + align-items: center; +} + +.ruleItem { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.ruleItemIcon { + color: var(--text-tertiary); + font-size: 0.875rem; +} + +.ruleItemName { + font-weight: 500; + color: var(--text-primary); + font-family: 'Monaco', 'Menlo', monospace; + font-size: 0.875rem; +} + +.ruleActions { + display: flex; + gap: 0.25rem; +} + +.iconButton { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + background: none; + border: 1px solid transparent; + border-radius: 4px; + cursor: pointer; + color: var(--text-tertiary); + transition: all 0.2s; +} + +.iconButton:hover { + background: var(--bg-tertiary); + color: var(--text-primary); + border-color: var(--border-color); +} + +.iconButton.danger:hover { + background: #fed7d7; + color: #c53030; + border-color: #fc8181; +} + +/* ============================================================================= + * Permissions Grid + * ============================================================================= */ + +.permissionsGrid { + display: grid; + grid-template-columns: repeat(5, 1fr); + gap: 0.5rem; +} + +.permissionItem { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.25rem; +} + +.permissionLabel { + font-size: 0.6875rem; + color: var(--text-tertiary); + text-transform: uppercase; + font-weight: 500; +} + +/* View Toggle */ +.viewToggle { + display: flex; + align-items: center; + justify-content: center; + width: 100%; +} + +.viewCheckbox { + width: 18px; + height: 18px; + cursor: pointer; + accent-color: var(--primary-color); +} + +/* ============================================================================= + * Empty State + * ============================================================================= */ + +.emptyState { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 2rem; + color: var(--text-tertiary); + text-align: center; +} + +.emptyIcon { + font-size: 2rem; + margin-bottom: 0.75rem; + opacity: 0.5; +} + +.emptyText { + font-size: 0.875rem; + margin: 0; +} + +.emptyHint { + font-size: 0.75rem; + margin-top: 0.25rem; +} + +/* ============================================================================= + * Add Rule Modal + * ============================================================================= */ + +.addRuleForm { + display: flex; + flex-direction: column; + gap: 1rem; +} + +.formGroup { + display: flex; + flex-direction: column; + gap: 0.375rem; +} + +.formLabel { + font-size: 0.8125rem; + font-weight: 500; + color: var(--text-primary); +} + +.formInput { + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 0.875rem; + background: var(--bg-primary); + color: var(--text-primary); +} + +.formInput:focus { + outline: none; + border-color: var(--primary-color); + box-shadow: 0 0 0 2px var(--primary-color-light); +} + +.formSelect { + padding: 0.5rem 0.75rem; + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 0.875rem; + background: var(--bg-primary); + color: var(--text-primary); + cursor: pointer; +} + +.formHint { + font-size: 0.75rem; + color: var(--text-tertiary); +} + +.formActions { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + margin-top: 0.5rem; +} + +/* ============================================================================= + * Action Bar + * ============================================================================= */ + +.actionBar { + display: flex; + justify-content: flex-end; + gap: 0.5rem; + padding-top: 1rem; + border-top: 1px solid var(--border-color); +} + +.secondaryButton { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 1rem; + background: var(--bg-secondary); + color: var(--text-primary); + border: 1px solid var(--border-color); + border-radius: 4px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.secondaryButton:hover { + background: var(--bg-tertiary); +} + +.secondaryButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.primaryButton { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 1rem; + background: var(--primary-color); + color: white; + border: none; + border-radius: 4px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.primaryButton:hover { + background: var(--primary-color-dark); +} + +.primaryButton:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* ============================================================================= + * Loading State + * ============================================================================= */ + +.loadingContainer { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + padding: 3rem; + gap: 1rem; +} + +.spinner { + width: 32px; + height: 32px; + border: 3px solid var(--border-color); + border-top-color: var(--primary-color); + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +/* ============================================================================= + * JSON Editor Tab + * ============================================================================= */ + +.jsonEditor { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.jsonTextarea { + width: 100%; + min-height: 300px; + padding: 0.75rem; + border: 1px solid var(--border-color); + border-radius: 4px; + font-family: 'Monaco', 'Menlo', monospace; + font-size: 0.8125rem; + line-height: 1.5; + background: var(--bg-secondary); + color: var(--text-primary); + resize: vertical; +} + +.jsonTextarea:focus { + outline: none; + border-color: var(--primary-color); +} + +.jsonError { + color: #c53030; + font-size: 0.8125rem; + padding: 0.5rem; + background: #fed7d7; + border-radius: 4px; +} + +.jsonHint { + font-size: 0.75rem; + color: var(--text-tertiary); +} diff --git a/src/components/AccessRules/AccessRulesEditor.tsx b/src/components/AccessRules/AccessRulesEditor.tsx new file mode 100644 index 0000000..4614027 --- /dev/null +++ b/src/components/AccessRules/AccessRulesEditor.tsx @@ -0,0 +1,626 @@ +/** + * AccessRulesEditor + * + * Main component for editing RBAC access rules for a role. + * Provides tabbed interface for DATA, UI, and RESOURCE rules. + */ + +import React, { useState, useEffect, useCallback } from 'react'; +import { + FaTable, + FaDesktop, + FaServer, + FaCode, + FaPlus, + FaTrash, + FaSave, + FaUndo, + FaSpinner, +} from 'react-icons/fa'; +import { + useAccessRules, + type AccessRule, + type RuleContext, + type AccessLevel, + type AccessRuleCreate, +} from '../../hooks/useAccessRules'; +import { AccessLevelSelect } from './AccessLevelSelect'; +import styles from './AccessRules.module.css'; + +// ============================================================================= +// TYPES +// ============================================================================= + +interface AccessRulesEditorProps { + roleId: string; + roleName?: string; + isTemplate?: boolean; + readOnly?: boolean; + onSave?: () => void; +} + +type TabType = 'DATA' | 'UI' | 'RESOURCE' | 'JSON'; + +// ============================================================================= +// RULE CARD COMPONENT +// ============================================================================= + +interface RuleCardProps { + rule: AccessRule; + readOnly?: boolean; + onUpdate: (ruleId: string, updates: Partial) => void; + onDelete: (ruleId: string) => void; +} + +const RuleCard: React.FC = ({ rule, readOnly, onUpdate, onDelete }) => { + const isDataRule = rule.context === 'DATA'; + + return ( +
+
+
+ + {rule.context === 'DATA' ? : + rule.context === 'UI' ? : } + + {rule.item || '(global)'} +
+ {!readOnly && ( +
+ +
+ )} +
+ +
+ {/* View Toggle */} +
+ View +
+ onUpdate(rule.id, { view: e.target.checked })} + disabled={readOnly} + className={styles.viewCheckbox} + /> +
+
+ + {/* CRUD Levels (only for DATA context) */} + {isDataRule ? ( + <> +
+ Read + onUpdate(rule.id, { read: value })} + disabled={readOnly} + compact + /> +
+
+ Create + onUpdate(rule.id, { create: value })} + disabled={readOnly} + compact + /> +
+
+ Update + onUpdate(rule.id, { update: value })} + disabled={readOnly} + compact + /> +
+
+ Delete + onUpdate(rule.id, { delete: value })} + disabled={readOnly} + compact + /> +
+ + ) : ( + // For UI and RESOURCE, show empty placeholders to maintain grid +
+ )} +
+
+ ); +}; + +// ============================================================================= +// ADD RULE FORM +// ============================================================================= + +interface AddRuleFormProps { + context: RuleContext; + onAdd: (rule: AccessRuleCreate) => void; + onCancel: () => void; +} + +const AddRuleForm: React.FC = ({ context, onAdd, onCancel }) => { + const [item, setItem] = useState(''); + const [view, setView] = useState(true); + const [read, setRead] = useState('n'); + const [create, setCreate] = useState('n'); + const [update, setUpdate] = useState('n'); + const [del, setDel] = useState('n'); + + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + const newRule: AccessRuleCreate = { + context, + item: item.trim() || null, + view, + ...(context === 'DATA' ? { read, create, update, delete: del } : {}), + }; + onAdd(newRule); + }; + + const getPlaceholder = () => { + switch (context) { + case 'DATA': + return 'z.B. TrusteeContract oder TrusteeContract.salary'; + case 'UI': + return 'z.B. nav.trustee oder button.export'; + case 'RESOURCE': + return 'z.B. ai.model.anthropic oder connector.sharepoint'; + } + }; + + return ( +
+
+ + setItem(e.target.value)} + placeholder={getPlaceholder()} + className={styles.formInput} + autoFocus + /> + + Leer lassen für globale Regel. Längster Match gewinnt. + +
+ +
+ +
+ + {context === 'DATA' && ( +
+
+ Read + +
+
+ Create + +
+
+ Update + +
+
+ Delete + +
+
+ )} + +
+ + +
+
+ ); +}; + +// ============================================================================= +// RULES SECTION +// ============================================================================= + +interface RulesSectionProps { + context: RuleContext; + rules: AccessRule[]; + readOnly?: boolean; + onUpdate: (ruleId: string, updates: Partial) => void; + onDelete: (ruleId: string) => void; + onAdd: (rule: AccessRuleCreate) => void; +} + +const RulesSection: React.FC = ({ + context, + rules, + readOnly, + onUpdate, + onDelete, + onAdd, +}) => { + const [showAddForm, setShowAddForm] = useState(false); + + const handleAdd = (rule: AccessRuleCreate) => { + onAdd(rule); + setShowAddForm(false); + }; + + const getEmptyIcon = () => { + switch (context) { + case 'DATA': return ; + case 'UI': return ; + case 'RESOURCE': return ; + } + }; + + const getEmptyText = () => { + switch (context) { + case 'DATA': return 'Keine Daten-Regeln definiert'; + case 'UI': return 'Keine UI-Regeln definiert'; + case 'RESOURCE': return 'Keine Ressourcen-Regeln definiert'; + } + }; + + return ( +
+ {!readOnly && !showAddForm && ( +
+ + {rules.length} {rules.length === 1 ? 'Regel' : 'Regeln'} + + +
+ )} + + {showAddForm && ( + setShowAddForm(false)} + /> + )} + + {rules.length === 0 && !showAddForm ? ( +
+
{getEmptyIcon()}
+

{getEmptyText()}

+ {!readOnly && ( +

+ Klicken Sie auf "Neue Regel" um eine Berechtigung hinzuzufügen. +

+ )} +
+ ) : ( + rules.map(rule => ( + + )) + )} +
+ ); +}; + +// ============================================================================= +// JSON EDITOR +// ============================================================================= + +interface JsonEditorProps { + rules: AccessRule[]; + readOnly?: boolean; + onApply: (rules: AccessRule[]) => void; +} + +const JsonEditor: React.FC = ({ rules, readOnly, onApply }) => { + const [jsonText, setJsonText] = useState(''); + const [error, setError] = useState(null); + + useEffect(() => { + setJsonText(JSON.stringify(rules, null, 2)); + setError(null); + }, [rules]); + + const handleApply = () => { + try { + const parsed = JSON.parse(jsonText); + if (!Array.isArray(parsed)) { + throw new Error('JSON muss ein Array sein'); + } + setError(null); + onApply(parsed); + } catch (err: any) { + setError(err.message); + } + }; + + return ( +
+