saas mandates core done
This commit is contained in:
parent
70c84dd897
commit
7f07a55c91
32 changed files with 6992 additions and 201 deletions
|
|
@ -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() {
|
|||
<Route path="/register" element={<Register />} />
|
||||
<Route path="/password-reset-request" element={<PasswordResetRequest />} />
|
||||
<Route path="/reset" element={<Reset />} />
|
||||
<Route path="/invite/:token" element={<InvitePage />} />
|
||||
|
||||
{/* ================================================== */}
|
||||
{/* PROTECTED ROUTES - REQUIRE AUTHENTICATION */}
|
||||
|
|
@ -119,6 +121,10 @@ function App() {
|
|||
<Route path="mandates" element={<AdminMandatesPage />} />
|
||||
<Route path="users" element={<AdminUsersPage />} />
|
||||
<Route path="roles" element={<AdminRolesPage />} />
|
||||
<Route path="user-mandates" element={<AdminUserMandatesPage />} />
|
||||
<Route path="feature-instances" element={<AdminFeatureAccessPage />} />
|
||||
<Route path="invitations" element={<AdminInvitationsPage />} />
|
||||
<Route path="mandate-roles" element={<AdminMandateRolesPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
||||
|
|
|
|||
59
src/components/AccessRules/AccessLevelSelect.tsx
Normal file
59
src/components/AccessRules/AccessLevelSelect.tsx
Normal file
|
|
@ -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<AccessLevelSelectProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
label,
|
||||
showLabel = false,
|
||||
compact = false,
|
||||
}) => {
|
||||
const currentColor = getAccessLevelColor(value);
|
||||
|
||||
return (
|
||||
<div className={`${styles.accessLevelSelect} ${compact ? styles.compact : ''}`}>
|
||||
{showLabel && label && (
|
||||
<label className={styles.accessLevelLabel}>{label}</label>
|
||||
)}
|
||||
<select
|
||||
value={value || 'n'}
|
||||
onChange={(e) => onChange(e.target.value as AccessLevel)}
|
||||
disabled={disabled}
|
||||
className={styles.accessLevelDropdown}
|
||||
style={{
|
||||
borderColor: currentColor,
|
||||
color: currentColor,
|
||||
}}
|
||||
>
|
||||
{ACCESS_LEVEL_OPTIONS.map(option => (
|
||||
<option
|
||||
key={option.value}
|
||||
value={option.value}
|
||||
style={{ color: option.color }}
|
||||
>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccessLevelSelect;
|
||||
535
src/components/AccessRules/AccessRules.module.css
Normal file
535
src/components/AccessRules/AccessRules.module.css
Normal file
|
|
@ -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);
|
||||
}
|
||||
626
src/components/AccessRules/AccessRulesEditor.tsx
Normal file
626
src/components/AccessRules/AccessRulesEditor.tsx
Normal file
|
|
@ -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<AccessRule>) => void;
|
||||
onDelete: (ruleId: string) => void;
|
||||
}
|
||||
|
||||
const RuleCard: React.FC<RuleCardProps> = ({ rule, readOnly, onUpdate, onDelete }) => {
|
||||
const isDataRule = rule.context === 'DATA';
|
||||
|
||||
return (
|
||||
<div className={styles.ruleCard}>
|
||||
<div className={styles.ruleHeader}>
|
||||
<div className={styles.ruleItem}>
|
||||
<span className={styles.ruleItemIcon}>
|
||||
{rule.context === 'DATA' ? <FaTable /> :
|
||||
rule.context === 'UI' ? <FaDesktop /> : <FaServer />}
|
||||
</span>
|
||||
<span className={styles.ruleItemName}>{rule.item || '(global)'}</span>
|
||||
</div>
|
||||
{!readOnly && (
|
||||
<div className={styles.ruleActions}>
|
||||
<button
|
||||
className={`${styles.iconButton} ${styles.danger}`}
|
||||
onClick={() => onDelete(rule.id)}
|
||||
title="Regel löschen"
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={styles.permissionsGrid}>
|
||||
{/* View Toggle */}
|
||||
<div className={styles.permissionItem}>
|
||||
<span className={styles.permissionLabel}>View</span>
|
||||
<div className={styles.viewToggle}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={rule.view}
|
||||
onChange={(e) => onUpdate(rule.id, { view: e.target.checked })}
|
||||
disabled={readOnly}
|
||||
className={styles.viewCheckbox}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* CRUD Levels (only for DATA context) */}
|
||||
{isDataRule ? (
|
||||
<>
|
||||
<div className={styles.permissionItem}>
|
||||
<span className={styles.permissionLabel}>Read</span>
|
||||
<AccessLevelSelect
|
||||
value={rule.read}
|
||||
onChange={(value) => onUpdate(rule.id, { read: value })}
|
||||
disabled={readOnly}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.permissionItem}>
|
||||
<span className={styles.permissionLabel}>Create</span>
|
||||
<AccessLevelSelect
|
||||
value={rule.create}
|
||||
onChange={(value) => onUpdate(rule.id, { create: value })}
|
||||
disabled={readOnly}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.permissionItem}>
|
||||
<span className={styles.permissionLabel}>Update</span>
|
||||
<AccessLevelSelect
|
||||
value={rule.update}
|
||||
onChange={(value) => onUpdate(rule.id, { update: value })}
|
||||
disabled={readOnly}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.permissionItem}>
|
||||
<span className={styles.permissionLabel}>Delete</span>
|
||||
<AccessLevelSelect
|
||||
value={rule.delete}
|
||||
onChange={(value) => onUpdate(rule.id, { delete: value })}
|
||||
disabled={readOnly}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
) : (
|
||||
// For UI and RESOURCE, show empty placeholders to maintain grid
|
||||
<div style={{ gridColumn: 'span 4' }} />
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// ADD RULE FORM
|
||||
// =============================================================================
|
||||
|
||||
interface AddRuleFormProps {
|
||||
context: RuleContext;
|
||||
onAdd: (rule: AccessRuleCreate) => void;
|
||||
onCancel: () => void;
|
||||
}
|
||||
|
||||
const AddRuleForm: React.FC<AddRuleFormProps> = ({ context, onAdd, onCancel }) => {
|
||||
const [item, setItem] = useState('');
|
||||
const [view, setView] = useState(true);
|
||||
const [read, setRead] = useState<AccessLevel>('n');
|
||||
const [create, setCreate] = useState<AccessLevel>('n');
|
||||
const [update, setUpdate] = useState<AccessLevel>('n');
|
||||
const [del, setDel] = useState<AccessLevel>('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 (
|
||||
<form className={styles.addRuleForm} onSubmit={handleSubmit}>
|
||||
<div className={styles.formGroup}>
|
||||
<label className={styles.formLabel}>Item (Dot-Notation)</label>
|
||||
<input
|
||||
type="text"
|
||||
value={item}
|
||||
onChange={(e) => setItem(e.target.value)}
|
||||
placeholder={getPlaceholder()}
|
||||
className={styles.formInput}
|
||||
autoFocus
|
||||
/>
|
||||
<span className={styles.formHint}>
|
||||
Leer lassen für globale Regel. Längster Match gewinnt.
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<label className={styles.formLabel}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={view}
|
||||
onChange={(e) => setView(e.target.checked)}
|
||||
style={{ marginRight: '0.5rem' }}
|
||||
/>
|
||||
Sichtbar (View)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{context === 'DATA' && (
|
||||
<div className={styles.permissionsGrid} style={{ marginTop: '0.5rem' }}>
|
||||
<div className={styles.permissionItem}>
|
||||
<span className={styles.permissionLabel}>Read</span>
|
||||
<AccessLevelSelect value={read} onChange={setRead} compact />
|
||||
</div>
|
||||
<div className={styles.permissionItem}>
|
||||
<span className={styles.permissionLabel}>Create</span>
|
||||
<AccessLevelSelect value={create} onChange={setCreate} compact />
|
||||
</div>
|
||||
<div className={styles.permissionItem}>
|
||||
<span className={styles.permissionLabel}>Update</span>
|
||||
<AccessLevelSelect value={update} onChange={setUpdate} compact />
|
||||
</div>
|
||||
<div className={styles.permissionItem}>
|
||||
<span className={styles.permissionLabel}>Delete</span>
|
||||
<AccessLevelSelect value={del} onChange={setDel} compact />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.formActions}>
|
||||
<button type="button" className={styles.secondaryButton} onClick={onCancel}>
|
||||
Abbrechen
|
||||
</button>
|
||||
<button type="submit" className={styles.primaryButton}>
|
||||
<FaPlus /> Hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// RULES SECTION
|
||||
// =============================================================================
|
||||
|
||||
interface RulesSectionProps {
|
||||
context: RuleContext;
|
||||
rules: AccessRule[];
|
||||
readOnly?: boolean;
|
||||
onUpdate: (ruleId: string, updates: Partial<AccessRule>) => void;
|
||||
onDelete: (ruleId: string) => void;
|
||||
onAdd: (rule: AccessRuleCreate) => void;
|
||||
}
|
||||
|
||||
const RulesSection: React.FC<RulesSectionProps> = ({
|
||||
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 <FaTable />;
|
||||
case 'UI': return <FaDesktop />;
|
||||
case 'RESOURCE': return <FaServer />;
|
||||
}
|
||||
};
|
||||
|
||||
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 (
|
||||
<div className={styles.rulesSection}>
|
||||
{!readOnly && !showAddForm && (
|
||||
<div className={styles.sectionHeader}>
|
||||
<span className={styles.sectionTitle}>
|
||||
{rules.length} {rules.length === 1 ? 'Regel' : 'Regeln'}
|
||||
</span>
|
||||
<button
|
||||
className={styles.addButton}
|
||||
onClick={() => setShowAddForm(true)}
|
||||
>
|
||||
<FaPlus /> Neue Regel
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showAddForm && (
|
||||
<AddRuleForm
|
||||
context={context}
|
||||
onAdd={handleAdd}
|
||||
onCancel={() => setShowAddForm(false)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{rules.length === 0 && !showAddForm ? (
|
||||
<div className={styles.emptyState}>
|
||||
<div className={styles.emptyIcon}>{getEmptyIcon()}</div>
|
||||
<p className={styles.emptyText}>{getEmptyText()}</p>
|
||||
{!readOnly && (
|
||||
<p className={styles.emptyHint}>
|
||||
Klicken Sie auf "Neue Regel" um eine Berechtigung hinzuzufügen.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
rules.map(rule => (
|
||||
<RuleCard
|
||||
key={rule.id}
|
||||
rule={rule}
|
||||
readOnly={readOnly}
|
||||
onUpdate={onUpdate}
|
||||
onDelete={onDelete}
|
||||
/>
|
||||
))
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// JSON EDITOR
|
||||
// =============================================================================
|
||||
|
||||
interface JsonEditorProps {
|
||||
rules: AccessRule[];
|
||||
readOnly?: boolean;
|
||||
onApply: (rules: AccessRule[]) => void;
|
||||
}
|
||||
|
||||
const JsonEditor: React.FC<JsonEditorProps> = ({ rules, readOnly, onApply }) => {
|
||||
const [jsonText, setJsonText] = useState('');
|
||||
const [error, setError] = useState<string | null>(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 (
|
||||
<div className={styles.jsonEditor}>
|
||||
<textarea
|
||||
value={jsonText}
|
||||
onChange={(e) => setJsonText(e.target.value)}
|
||||
className={styles.jsonTextarea}
|
||||
readOnly={readOnly}
|
||||
spellCheck={false}
|
||||
/>
|
||||
{error && <div className={styles.jsonError}>{error}</div>}
|
||||
<p className={styles.jsonHint}>
|
||||
Experten-Modus: Bearbeiten Sie die Regeln direkt als JSON.
|
||||
Änderungen werden erst nach Klick auf "Anwenden" übernommen.
|
||||
</p>
|
||||
{!readOnly && (
|
||||
<div className={styles.formActions}>
|
||||
<button
|
||||
type="button"
|
||||
className={styles.primaryButton}
|
||||
onClick={handleApply}
|
||||
disabled={!!error}
|
||||
>
|
||||
JSON anwenden
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
export const AccessRulesEditor: React.FC<AccessRulesEditorProps> = ({
|
||||
roleId,
|
||||
roleName,
|
||||
isTemplate = false,
|
||||
readOnly = false,
|
||||
onSave,
|
||||
}) => {
|
||||
const {
|
||||
rules,
|
||||
loading,
|
||||
saving,
|
||||
error,
|
||||
fetchRules,
|
||||
saveRules,
|
||||
getGroupedRules,
|
||||
updateRuleLocally,
|
||||
addRuleLocally,
|
||||
removeRuleLocally,
|
||||
} = useAccessRules(roleId);
|
||||
|
||||
const [activeTab, setActiveTab] = useState<TabType>('DATA');
|
||||
const [hasChanges, setHasChanges] = useState(false);
|
||||
const [originalRules, setOriginalRules] = useState<AccessRule[]>([]);
|
||||
|
||||
// Load rules on mount
|
||||
useEffect(() => {
|
||||
fetchRules().then(fetchedRules => {
|
||||
setOriginalRules(fetchedRules);
|
||||
});
|
||||
}, [fetchRules]);
|
||||
|
||||
// Track changes
|
||||
useEffect(() => {
|
||||
setHasChanges(JSON.stringify(rules) !== JSON.stringify(originalRules));
|
||||
}, [rules, originalRules]);
|
||||
|
||||
const groupedRules = getGroupedRules();
|
||||
|
||||
// Handlers
|
||||
const handleUpdate = useCallback((ruleId: string, updates: Partial<AccessRule>) => {
|
||||
updateRuleLocally(ruleId, updates);
|
||||
}, [updateRuleLocally]);
|
||||
|
||||
const handleDelete = useCallback((ruleId: string) => {
|
||||
if (window.confirm('Möchten Sie diese Regel wirklich löschen?')) {
|
||||
removeRuleLocally(ruleId);
|
||||
}
|
||||
}, [removeRuleLocally]);
|
||||
|
||||
const handleAdd = useCallback((ruleData: AccessRuleCreate) => {
|
||||
const newRule: AccessRule = {
|
||||
id: `temp-${Date.now()}`, // Temporary ID
|
||||
roleId,
|
||||
context: ruleData.context,
|
||||
item: ruleData.item || null,
|
||||
view: ruleData.view ?? true,
|
||||
read: ruleData.read ?? null,
|
||||
create: ruleData.create ?? null,
|
||||
update: ruleData.update ?? null,
|
||||
delete: ruleData.delete ?? null,
|
||||
};
|
||||
addRuleLocally(newRule);
|
||||
}, [roleId, addRuleLocally]);
|
||||
|
||||
const handleSave = async () => {
|
||||
const result = await saveRules(rules);
|
||||
if (result.success) {
|
||||
setOriginalRules(rules);
|
||||
setHasChanges(false);
|
||||
onSave?.();
|
||||
} else {
|
||||
alert(result.error || 'Fehler beim Speichern');
|
||||
}
|
||||
};
|
||||
|
||||
const handleReset = () => {
|
||||
if (window.confirm('Alle Änderungen verwerfen?')) {
|
||||
fetchRules().then(fetchedRules => {
|
||||
setOriginalRules(fetchedRules);
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleJsonApply = (newRules: AccessRule[]) => {
|
||||
// Replace all rules
|
||||
newRules.forEach((rule, index) => {
|
||||
if (!rule.id) {
|
||||
rule.id = `temp-${Date.now()}-${index}`;
|
||||
}
|
||||
rule.roleId = roleId;
|
||||
});
|
||||
// This is a bit hacky - we need to update the store
|
||||
// For now, we'll just save directly
|
||||
saveRules(newRules);
|
||||
};
|
||||
|
||||
// Render tabs
|
||||
const tabs: { id: TabType; label: string; icon: React.ReactNode; count: number }[] = [
|
||||
{ id: 'DATA', label: 'Daten', icon: <FaTable />, count: groupedRules.DATA.length },
|
||||
{ id: 'UI', label: 'UI', icon: <FaDesktop />, count: groupedRules.UI.length },
|
||||
{ id: 'RESOURCE', label: 'Ressourcen', icon: <FaServer />, count: groupedRules.RESOURCE.length },
|
||||
{ id: 'JSON', label: 'JSON', icon: <FaCode />, count: rules.length },
|
||||
];
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className={styles.accessRulesEditor}>
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Lade Berechtigungen...</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.accessRulesEditor}>
|
||||
<div className={styles.editorHeader}>
|
||||
<h3 className={styles.editorTitle}>
|
||||
Berechtigungen{roleName ? `: ${roleName}` : ''}
|
||||
{isTemplate && <span className={styles.templateBadge}>Template</span>}
|
||||
</h3>
|
||||
{!readOnly && hasChanges && (
|
||||
<div className={styles.headerActions}>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
onClick={handleReset}
|
||||
disabled={saving}
|
||||
>
|
||||
<FaUndo /> Zurücksetzen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={styles.jsonError}>
|
||||
Fehler: {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.tabsContainer}>
|
||||
<div className={styles.tabList}>
|
||||
{tabs.map(tab => (
|
||||
<button
|
||||
key={tab.id}
|
||||
className={`${styles.tab} ${activeTab === tab.id ? styles.active : ''}`}
|
||||
onClick={() => setActiveTab(tab.id)}
|
||||
>
|
||||
<span className={styles.tabIcon}>{tab.icon}</span>
|
||||
{tab.label}
|
||||
<span className={styles.tabBadge}>{tab.count}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className={styles.tabContent}>
|
||||
{activeTab === 'DATA' && (
|
||||
<RulesSection
|
||||
context="DATA"
|
||||
rules={groupedRules.DATA}
|
||||
readOnly={readOnly}
|
||||
onUpdate={handleUpdate}
|
||||
onDelete={handleDelete}
|
||||
onAdd={handleAdd}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'UI' && (
|
||||
<RulesSection
|
||||
context="UI"
|
||||
rules={groupedRules.UI}
|
||||
readOnly={readOnly}
|
||||
onUpdate={handleUpdate}
|
||||
onDelete={handleDelete}
|
||||
onAdd={handleAdd}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'RESOURCE' && (
|
||||
<RulesSection
|
||||
context="RESOURCE"
|
||||
rules={groupedRules.RESOURCE}
|
||||
readOnly={readOnly}
|
||||
onUpdate={handleUpdate}
|
||||
onDelete={handleDelete}
|
||||
onAdd={handleAdd}
|
||||
/>
|
||||
)}
|
||||
{activeTab === 'JSON' && (
|
||||
<JsonEditor
|
||||
rules={rules}
|
||||
readOnly={readOnly}
|
||||
onApply={handleJsonApply}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!readOnly && (
|
||||
<div className={styles.actionBar}>
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={handleSave}
|
||||
disabled={saving || !hasChanges}
|
||||
>
|
||||
{saving ? (
|
||||
<>
|
||||
<FaSpinner className="spinning" /> Speichern...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FaSave /> Speichern
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AccessRulesEditor;
|
||||
8
src/components/AccessRules/index.ts
Normal file
8
src/components/AccessRules/index.ts
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
/**
|
||||
* AccessRules Components
|
||||
*
|
||||
* Components for editing RBAC access rules.
|
||||
*/
|
||||
|
||||
export { AccessRulesEditor } from './AccessRulesEditor';
|
||||
export { AccessLevelSelect } from './AccessLevelSelect';
|
||||
|
|
@ -298,6 +298,12 @@
|
|||
overflow: visible;
|
||||
}
|
||||
|
||||
/* FK Loading state - shows truncated ID while loading */
|
||||
.fkLoading {
|
||||
color: var(--color-text);
|
||||
opacity: 0.6;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.tr {
|
||||
transition: background-color 0.2s ease;
|
||||
|
|
|
|||
|
|
@ -17,6 +17,10 @@ import {
|
|||
} from '../../../utils/attributeTypeMapper';
|
||||
import type { AttributeType } from '../../../utils/attributeTypeMapper';
|
||||
import { FaFilter } from 'react-icons/fa';
|
||||
import api from '../../../api';
|
||||
|
||||
// FK Cache type: maps fkSource -> { id -> displayLabel }
|
||||
type FkCacheType = Record<string, Record<string, string>>;
|
||||
|
||||
// Helper function to detect TextMultilingual objects
|
||||
// TextMultilingual has structure: { en: string, ge?: string, fr?: string, it?: string }
|
||||
|
|
@ -80,6 +84,8 @@ export interface ColumnConfig {
|
|||
formatter?: (value: any, row: any) => React.ReactNode;
|
||||
filterOptions?: string[]; // For enum/select filters
|
||||
cellClassName?: (value: any, row: any) => string; // For custom cell styling
|
||||
fkSource?: string; // API endpoint for FK resolution (e.g., "/api/users/")
|
||||
fkDisplayField?: string; // Which field of FK target to display (e.g., "username", "name", "roleLabel")
|
||||
}
|
||||
|
||||
export interface FormGeneratorTableProps<T = any> {
|
||||
|
|
@ -296,6 +302,11 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
const [openFilterColumn, setOpenFilterColumn] = useState<string | null>(null);
|
||||
const filterDropdownRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// FK Resolution: Cache for resolved FK values (fkSource -> { id -> displayLabel })
|
||||
const [fkCache, setFkCache] = useState<FkCacheType>({});
|
||||
const [fkLoading, setFkLoading] = useState<Record<string, boolean>>({});
|
||||
const fkLoadedSourcesRef = useRef<Set<string>>(new Set());
|
||||
|
||||
// Generate a storage key based on column names for localStorage persistence
|
||||
const storageKey = useMemo(() => {
|
||||
if (detectedColumns.length === 0) return null;
|
||||
|
|
@ -445,6 +456,161 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
}
|
||||
}).current;
|
||||
|
||||
// Helper function to convert any field value to display string
|
||||
// Handles: string, boolean, number, TextMultilingual, objects
|
||||
const convertToDisplayString = useCallback((fieldValue: any, language: string): string => {
|
||||
if (fieldValue === null || fieldValue === undefined) {
|
||||
return '-';
|
||||
}
|
||||
|
||||
// Boolean → language-neutral symbols (✓/✗)
|
||||
if (typeof fieldValue === 'boolean') {
|
||||
return fieldValue ? '✓' : '✗';
|
||||
}
|
||||
|
||||
// Number → String
|
||||
if (typeof fieldValue === 'number') {
|
||||
return String(fieldValue);
|
||||
}
|
||||
|
||||
// String → direct
|
||||
if (typeof fieldValue === 'string') {
|
||||
return fieldValue;
|
||||
}
|
||||
|
||||
// Object - check for TextMultilingual (has 'en' key)
|
||||
if (typeof fieldValue === 'object' && fieldValue !== null) {
|
||||
// TextMultilingual: { en: "...", ge: "...", fr: "...", it: "..." }
|
||||
if ('en' in fieldValue) {
|
||||
// Map frontend language codes to backend codes
|
||||
const langMap: Record<string, string> = { 'de': 'ge', 'en': 'en', 'fr': 'fr', 'it': 'it' };
|
||||
const backendLang = langMap[language] || language;
|
||||
|
||||
// Try current language first, then fallback
|
||||
if (fieldValue[backendLang] && typeof fieldValue[backendLang] === 'string' && fieldValue[backendLang].trim()) {
|
||||
return fieldValue[backendLang];
|
||||
}
|
||||
if (fieldValue.en && typeof fieldValue.en === 'string' && fieldValue.en.trim()) {
|
||||
return fieldValue.en;
|
||||
}
|
||||
// Try other languages
|
||||
for (const lang of ['ge', 'fr', 'it']) {
|
||||
if (fieldValue[lang] && typeof fieldValue[lang] === 'string' && fieldValue[lang].trim()) {
|
||||
return fieldValue[lang];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Other objects → try to stringify
|
||||
try {
|
||||
return JSON.stringify(fieldValue);
|
||||
} catch {
|
||||
return String(fieldValue);
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback
|
||||
return String(fieldValue);
|
||||
}, []);
|
||||
|
||||
// FK Resolution: Load FK data in bulk for columns with fkSource
|
||||
useEffect(() => {
|
||||
if (data.length === 0 || detectedColumns.length === 0) return;
|
||||
|
||||
// Find columns with fkSource that haven't been loaded yet
|
||||
const fkColumns = detectedColumns.filter(col =>
|
||||
col.fkSource && !fkLoadedSourcesRef.current.has(col.fkSource)
|
||||
);
|
||||
|
||||
if (fkColumns.length === 0) return;
|
||||
|
||||
// For each FK column, collect unique IDs from data and fetch them
|
||||
const loadFkData = async () => {
|
||||
for (const column of fkColumns) {
|
||||
const fkSource = column.fkSource!;
|
||||
const displayField = column.fkDisplayField; // Explicit field from Pydantic model
|
||||
|
||||
// Skip if already loading
|
||||
if (fkLoading[fkSource]) continue;
|
||||
|
||||
// Collect unique IDs from data for this column
|
||||
const uniqueIds = new Set<string>();
|
||||
data.forEach(row => {
|
||||
const value = row[column.key];
|
||||
if (value && typeof value === 'string' && value.length > 0) {
|
||||
uniqueIds.add(value);
|
||||
}
|
||||
});
|
||||
|
||||
if (uniqueIds.size === 0) {
|
||||
fkLoadedSourcesRef.current.add(fkSource);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Mark as loading
|
||||
setFkLoading(prev => ({ ...prev, [fkSource]: true }));
|
||||
|
||||
try {
|
||||
// Fetch all items from the FK source endpoint
|
||||
const response = await api.get(fkSource);
|
||||
|
||||
// Build cache: id -> display label
|
||||
const cacheForSource: Record<string, string> = {};
|
||||
const items = Array.isArray(response.data) ? response.data : response.data?.items || [];
|
||||
|
||||
items.forEach((item: any) => {
|
||||
if (!item || !item.id) return;
|
||||
|
||||
let displayLabel = item.id; // Fallback to ID
|
||||
|
||||
// Use the EXPLICIT display field from Pydantic model (fkDisplayField)
|
||||
if (displayField && item[displayField] !== undefined) {
|
||||
displayLabel = convertToDisplayString(item[displayField], currentLanguage);
|
||||
} else {
|
||||
// Fallback: if no displayField specified, try common fields
|
||||
// This should rarely happen if models are properly configured
|
||||
const fallbackFields = ['name', 'label', 'username', 'roleLabel', 'title'];
|
||||
for (const field of fallbackFields) {
|
||||
if (item[field] !== undefined) {
|
||||
displayLabel = convertToDisplayString(item[field], currentLanguage);
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cacheForSource[item.id] = displayLabel;
|
||||
});
|
||||
|
||||
// Update cache
|
||||
setFkCache(prev => ({
|
||||
...prev,
|
||||
[fkSource]: { ...(prev[fkSource] || {}), ...cacheForSource }
|
||||
}));
|
||||
|
||||
// Mark as loaded
|
||||
fkLoadedSourcesRef.current.add(fkSource);
|
||||
} catch (error) {
|
||||
console.error(`Failed to load FK data from ${fkSource}:`, error);
|
||||
// Mark as loaded to prevent infinite retries
|
||||
fkLoadedSourcesRef.current.add(fkSource);
|
||||
} finally {
|
||||
setFkLoading(prev => ({ ...prev, [fkSource]: false }));
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
loadFkData();
|
||||
}, [data, detectedColumns, currentLanguage, fkLoading, convertToDisplayString]);
|
||||
|
||||
// Helper function to resolve FK value to display label
|
||||
const resolveFkValue = useCallback((value: string, fkSource: string): string => {
|
||||
const sourceCache = fkCache[fkSource];
|
||||
if (sourceCache && sourceCache[value]) {
|
||||
return sourceCache[value];
|
||||
}
|
||||
// Return truncated ID while loading or if not found
|
||||
return value.length > 8 ? `${value.substring(0, 8)}...` : value;
|
||||
}, [fkCache]);
|
||||
|
||||
// Data is already filtered, sorted, and paginated by the backend
|
||||
// No client-side processing needed
|
||||
|
|
@ -916,6 +1082,19 @@ export function FormGeneratorTable<T extends Record<string, any>>({
|
|||
return renderBooleanCell(value, column, row);
|
||||
}
|
||||
|
||||
// FK Resolution: If column has fkSource and value is a string (UUID), resolve to display label
|
||||
if (column.fkSource && typeof value === 'string' && value.length > 0) {
|
||||
const resolvedLabel = resolveFkValue(value, column.fkSource);
|
||||
const isLoading = fkLoading[column.fkSource];
|
||||
|
||||
// Show loading indicator or resolved label
|
||||
if (isLoading && !fkCache[column.fkSource]?.[value]) {
|
||||
return <span className={styles.fkLoading}>{value.substring(0, 8)}...</span>;
|
||||
}
|
||||
|
||||
return resolvedLabel;
|
||||
}
|
||||
|
||||
// Check if this is an ID or hash field that should be truncated and copyable
|
||||
// Do this BEFORE checking for custom formatters to ensure IDs/hashes are always copyable
|
||||
const isId = isIdField(column.key);
|
||||
|
|
|
|||
|
|
@ -22,7 +22,7 @@ import { useMandates, useFeatureStore } from '../../stores/featureStore';
|
|||
import { useCurrentUser } from '../../hooks/useUsers';
|
||||
import { FEATURE_REGISTRY, getLabel } from '../../types/mandate';
|
||||
import type { Mandate, MandateFeature, FeatureInstance } from '../../types/mandate';
|
||||
import { FaHome, FaCog, FaBriefcase, FaRobot, FaPlay, FaBuilding, FaUsers, FaUserShield } from 'react-icons/fa';
|
||||
import { FaHome, FaCog, FaBriefcase, FaRobot, FaPlay, FaBuilding, FaUsers, FaUserShield, FaUserTag, FaCubes, FaEnvelopeOpenText, FaKey } from 'react-icons/fa';
|
||||
import { TreeNavigation, type TreeItem, type TreeNodeItem } from './TreeNavigation';
|
||||
import styles from './MandateNavigation.module.css';
|
||||
|
||||
|
|
@ -195,24 +195,48 @@ export const MandateNavigation: React.FC = () => {
|
|||
type: 'section',
|
||||
title: 'ADMINISTRATION',
|
||||
children: [
|
||||
{
|
||||
id: 'admin-mandates',
|
||||
label: 'Mandanten',
|
||||
icon: <FaBuilding />,
|
||||
path: '/admin/mandates',
|
||||
},
|
||||
{
|
||||
id: 'admin-users',
|
||||
label: 'Benutzer',
|
||||
icon: <FaUsers />,
|
||||
path: '/admin/users',
|
||||
},
|
||||
{
|
||||
id: 'admin-invitations',
|
||||
label: 'Einladungen',
|
||||
icon: <FaEnvelopeOpenText />,
|
||||
path: '/admin/invitations',
|
||||
},
|
||||
{
|
||||
id: 'admin-roles',
|
||||
label: 'Globale Rollen',
|
||||
icon: <FaUserShield />,
|
||||
path: '/admin/roles',
|
||||
},
|
||||
{
|
||||
id: 'admin-mandates',
|
||||
label: 'Mandanten',
|
||||
icon: <FaBuilding />,
|
||||
path: '/admin/mandates',
|
||||
},
|
||||
{
|
||||
id: 'admin-mandate-roles',
|
||||
label: 'Mandanten-Rollen',
|
||||
icon: <FaKey />,
|
||||
path: '/admin/mandate-roles',
|
||||
},
|
||||
{
|
||||
id: 'admin-user-mandates',
|
||||
label: 'Mandanten-Mitglieder',
|
||||
icon: <FaUserTag />,
|
||||
path: '/admin/user-mandates',
|
||||
},
|
||||
{
|
||||
id: 'admin-feature-instances',
|
||||
label: 'Feature-Instanzen',
|
||||
icon: <FaCubes />,
|
||||
path: '/admin/feature-instances',
|
||||
},
|
||||
],
|
||||
});
|
||||
}
|
||||
|
|
|
|||
507
src/components/RbacExportImport/RbacExportImport.module.css
Normal file
507
src/components/RbacExportImport/RbacExportImport.module.css
Normal file
|
|
@ -0,0 +1,507 @@
|
|||
/* =============================================================================
|
||||
* RBAC Export/Import Component Styles
|
||||
* ============================================================================= */
|
||||
|
||||
.rbacExportImport {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.rbacExportImport {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* Section
|
||||
* ============================================================================= */
|
||||
|
||||
.section {
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.sectionHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.sectionIcon {
|
||||
color: var(--primary-color);
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.sectionTitle {
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.sectionContent {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.sectionDescription {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* Buttons
|
||||
* ============================================================================= */
|
||||
|
||||
.primaryButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.625rem 1rem;
|
||||
background: var(--primary-color);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
}
|
||||
|
||||
.primaryButton:hover:not(:disabled) {
|
||||
background: var(--primary-color-dark);
|
||||
}
|
||||
|
||||
.primaryButton:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.primaryButton.danger {
|
||||
background: #c53030;
|
||||
}
|
||||
|
||||
.primaryButton.danger:hover:not(:disabled) {
|
||||
background: #9b2c2c;
|
||||
}
|
||||
|
||||
.clearButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: none;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.clearButton:hover {
|
||||
background: #fed7d7;
|
||||
color: #c53030;
|
||||
border-color: #fc8181;
|
||||
}
|
||||
|
||||
.previewButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.75rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 4px;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.previewButton:hover {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-tertiary);
|
||||
cursor: pointer;
|
||||
padding: 0.25rem;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
.closeButton:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* File Upload
|
||||
* ============================================================================= */
|
||||
|
||||
.fileUpload {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.fileInput {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.fileLabel {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border: 2px dashed var(--border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.fileLabel:hover {
|
||||
border-color: var(--primary-color);
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.fileIcon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* Import Info & Stats
|
||||
* ============================================================================= */
|
||||
|
||||
.importInfo {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem;
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.importStats {
|
||||
display: flex;
|
||||
gap: 1rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* Import Mode Selection
|
||||
* ============================================================================= */
|
||||
|
||||
.importModeSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.importModeTitle {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
margin: 0;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.importModes {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.importModeOption {
|
||||
display: grid;
|
||||
grid-template-columns: auto auto 1fr;
|
||||
grid-template-rows: auto auto;
|
||||
gap: 0.25rem 0.5rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.importModeOption:hover {
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.importModeOption.selected {
|
||||
border-color: var(--primary-color);
|
||||
background: var(--primary-color-light);
|
||||
}
|
||||
|
||||
.radioInput {
|
||||
grid-row: span 2;
|
||||
align-self: center;
|
||||
accent-color: var(--primary-color);
|
||||
}
|
||||
|
||||
.modeIcon {
|
||||
grid-row: span 2;
|
||||
align-self: center;
|
||||
font-size: 1.125rem;
|
||||
}
|
||||
|
||||
.modeLabel {
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.modeDescription {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-tertiary);
|
||||
grid-column: 3;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* Messages
|
||||
* ============================================================================= */
|
||||
|
||||
.errorMessage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: #fed7d7;
|
||||
color: #c53030;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.warningMessage {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem;
|
||||
background: #fefcbf;
|
||||
color: #744210;
|
||||
border-radius: 6px;
|
||||
font-size: 0.8125rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* Modal
|
||||
* ============================================================================= */
|
||||
|
||||
.modalOverlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal {
|
||||
background: var(--bg-primary);
|
||||
border-radius: 8px;
|
||||
max-width: 500px;
|
||||
width: 100%;
|
||||
max-height: 80vh;
|
||||
overflow: auto;
|
||||
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* Preview
|
||||
* ============================================================================= */
|
||||
|
||||
.preview {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.previewHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.previewTitle {
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.previewContent {
|
||||
padding: 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.previewSection {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.previewSection h5 {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.previewList {
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-primary);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.previewList code {
|
||||
background: var(--bg-secondary);
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 3px;
|
||||
font-family: 'Monaco', 'Menlo', monospace;
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.featureBadge,
|
||||
.contextBadge {
|
||||
display: inline-block;
|
||||
padding: 0.125rem 0.375rem;
|
||||
background: var(--primary-color-light);
|
||||
color: var(--primary-color);
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
border-radius: 3px;
|
||||
margin-left: 0.5rem;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.contextBadge {
|
||||
background: var(--bg-tertiary);
|
||||
color: var(--text-secondary);
|
||||
margin-left: 0;
|
||||
margin-right: 0.5rem;
|
||||
}
|
||||
|
||||
.moreItems {
|
||||
color: var(--text-tertiary);
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* Import Result
|
||||
* ============================================================================= */
|
||||
|
||||
.importResult {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.resultHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 1rem;
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.importResult.success .resultHeader {
|
||||
background: #c6f6d5;
|
||||
}
|
||||
|
||||
.importResult.error .resultHeader {
|
||||
background: #fed7d7;
|
||||
}
|
||||
|
||||
.resultIcon {
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
.importResult.success .resultIcon {
|
||||
color: #38a169;
|
||||
}
|
||||
|
||||
.importResult.error .resultIcon {
|
||||
color: #c53030;
|
||||
}
|
||||
|
||||
.resultTitle {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.resultContent {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.resultStats {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
list-style: none;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.resultErrors {
|
||||
margin-top: 1rem;
|
||||
padding-top: 1rem;
|
||||
border-top: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.resultErrors h5 {
|
||||
margin: 0 0 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: #c53030;
|
||||
}
|
||||
|
||||
.resultErrors ul {
|
||||
margin: 0;
|
||||
padding-left: 1.25rem;
|
||||
font-size: 0.8125rem;
|
||||
color: #c53030;
|
||||
}
|
||||
|
||||
/* =============================================================================
|
||||
* Spinning Animation
|
||||
* ============================================================================= */
|
||||
|
||||
.spinning {
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
from { transform: rotate(0deg); }
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
459
src/components/RbacExportImport/RbacExportImport.tsx
Normal file
459
src/components/RbacExportImport/RbacExportImport.tsx
Normal file
|
|
@ -0,0 +1,459 @@
|
|||
/**
|
||||
* RbacExportImport
|
||||
*
|
||||
* Component for exporting and importing RBAC configurations.
|
||||
* Supports mandate-level and global exports with different import modes.
|
||||
*/
|
||||
|
||||
import React, { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import {
|
||||
FaDownload,
|
||||
FaUpload,
|
||||
FaFileExport,
|
||||
FaFileImport,
|
||||
FaSpinner,
|
||||
FaCheckCircle,
|
||||
FaExclamationTriangle,
|
||||
FaInfoCircle,
|
||||
FaTrash,
|
||||
FaEye,
|
||||
} from 'react-icons/fa';
|
||||
import {
|
||||
useRbacExportImport,
|
||||
type RbacExport,
|
||||
type ImportMode,
|
||||
type RbacImportResult,
|
||||
} from '../../hooks/useRbacExportImport';
|
||||
import styles from './RbacExportImport.module.css';
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
interface RbacExportImportProps {
|
||||
mandateId?: string;
|
||||
mandateName?: string;
|
||||
isGlobal?: boolean;
|
||||
featureCode?: string;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// IMPORT MODE OPTIONS
|
||||
// =============================================================================
|
||||
|
||||
const IMPORT_MODES: { value: ImportMode; label: string; description: string; icon: React.ReactNode }[] = [
|
||||
{
|
||||
value: 'merge',
|
||||
label: 'Zusammenführen',
|
||||
description: 'Bestehende Regeln aktualisieren, neue hinzufügen',
|
||||
icon: <FaCheckCircle style={{ color: '#38a169' }} />,
|
||||
},
|
||||
{
|
||||
value: 'add_only',
|
||||
label: 'Nur hinzufügen',
|
||||
description: 'Nur neue Regeln hinzufügen, bestehende nicht ändern',
|
||||
icon: <FaInfoCircle style={{ color: '#3182ce' }} />,
|
||||
},
|
||||
{
|
||||
value: 'replace',
|
||||
label: 'Ersetzen',
|
||||
description: 'Alle bestehenden Regeln löschen und ersetzen',
|
||||
icon: <FaExclamationTriangle style={{ color: '#d69e2e' }} />,
|
||||
},
|
||||
];
|
||||
|
||||
// =============================================================================
|
||||
// PREVIEW COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
interface PreviewProps {
|
||||
data: RbacExport;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ExportPreview: React.FC<PreviewProps> = ({ data, onClose }) => {
|
||||
return (
|
||||
<div className={styles.preview}>
|
||||
<div className={styles.previewHeader}>
|
||||
<h4 className={styles.previewTitle}>Export-Vorschau</h4>
|
||||
<button className={styles.closeButton} onClick={onClose}>✕</button>
|
||||
</div>
|
||||
<div className={styles.previewContent}>
|
||||
<div className={styles.previewSection}>
|
||||
<h5>Scope</h5>
|
||||
<ul className={styles.previewList}>
|
||||
<li><strong>Typ:</strong> {data.scope.type}</li>
|
||||
{data.scope.mandateName && <li><strong>Mandant:</strong> {data.scope.mandateName}</li>}
|
||||
{data.scope.featureCode && <li><strong>Feature:</strong> {data.scope.featureCode}</li>}
|
||||
</ul>
|
||||
</div>
|
||||
<div className={styles.previewSection}>
|
||||
<h5>Rollen ({data.roles.length})</h5>
|
||||
<ul className={styles.previewList}>
|
||||
{data.roles.slice(0, 5).map((role, i) => (
|
||||
<li key={i}>
|
||||
<code>{role.roleLabel}</code>
|
||||
{role.featureCode && <span className={styles.featureBadge}>{role.featureCode}</span>}
|
||||
</li>
|
||||
))}
|
||||
{data.roles.length > 5 && (
|
||||
<li className={styles.moreItems}>... und {data.roles.length - 5} weitere</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
<div className={styles.previewSection}>
|
||||
<h5>Regeln ({data.accessRules.length})</h5>
|
||||
<ul className={styles.previewList}>
|
||||
{data.accessRules.slice(0, 5).map((rule, i) => (
|
||||
<li key={i}>
|
||||
<span className={styles.contextBadge}>{rule.context}</span>
|
||||
<code>{rule.item || '(global)'}</code>
|
||||
</li>
|
||||
))}
|
||||
{data.accessRules.length > 5 && (
|
||||
<li className={styles.moreItems}>... und {data.accessRules.length - 5} weitere</li>
|
||||
)}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// IMPORT RESULT COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
interface ImportResultProps {
|
||||
result: RbacImportResult;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
const ImportResult: React.FC<ImportResultProps> = ({ result, onClose }) => {
|
||||
const isSuccess = result.status === 'success';
|
||||
|
||||
return (
|
||||
<div className={`${styles.importResult} ${isSuccess ? styles.success : styles.error}`}>
|
||||
<div className={styles.resultHeader}>
|
||||
{isSuccess ? (
|
||||
<FaCheckCircle className={styles.resultIcon} />
|
||||
) : (
|
||||
<FaExclamationTriangle className={styles.resultIcon} />
|
||||
)}
|
||||
<h4 className={styles.resultTitle}>
|
||||
{isSuccess ? 'Import erfolgreich' : 'Import fehlgeschlagen'}
|
||||
</h4>
|
||||
<button className={styles.closeButton} onClick={onClose}>✕</button>
|
||||
</div>
|
||||
<div className={styles.resultContent}>
|
||||
<ul className={styles.resultStats}>
|
||||
<li><strong>Modus:</strong> {IMPORT_MODES.find(m => m.value === result.mode)?.label}</li>
|
||||
<li><strong>Rollen erstellt:</strong> {result.rolesCreated}</li>
|
||||
<li><strong>Rollen aktualisiert:</strong> {result.rolesUpdated}</li>
|
||||
<li><strong>Regeln erstellt:</strong> {result.rulesCreated}</li>
|
||||
<li><strong>Regeln aktualisiert:</strong> {result.rulesUpdated}</li>
|
||||
</ul>
|
||||
{result.errors && result.errors.length > 0 && (
|
||||
<div className={styles.resultErrors}>
|
||||
<h5>Fehler:</h5>
|
||||
<ul>
|
||||
{result.errors.map((err, i) => (
|
||||
<li key={i}>{err}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// MAIN COMPONENT
|
||||
// =============================================================================
|
||||
|
||||
export const RbacExportImport: React.FC<RbacExportImportProps> = ({
|
||||
mandateId,
|
||||
mandateName,
|
||||
isGlobal = false,
|
||||
featureCode,
|
||||
}) => {
|
||||
const {
|
||||
exporting,
|
||||
importing,
|
||||
error,
|
||||
lastExport,
|
||||
lastImportResult,
|
||||
exportMandateRbac,
|
||||
exportGlobalRbac,
|
||||
importMandateRbac,
|
||||
importGlobalRbac,
|
||||
downloadExport,
|
||||
parseImportFile,
|
||||
reset,
|
||||
} = useRbacExportImport();
|
||||
|
||||
const [importMode, setImportMode] = useState<ImportMode>('merge');
|
||||
const [importFile, setImportFile] = useState<File | null>(null);
|
||||
const [importData, setImportData] = useState<RbacExport | null>(null);
|
||||
const [parseError, setParseError] = useState<string | null>(null);
|
||||
const [showPreview, setShowPreview] = useState(false);
|
||||
const [showResult, setShowResult] = useState(false);
|
||||
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// Handle export
|
||||
const handleExport = async () => {
|
||||
let result;
|
||||
if (isGlobal) {
|
||||
result = await exportGlobalRbac(featureCode);
|
||||
} else if (mandateId) {
|
||||
result = await exportMandateRbac(mandateId, featureCode);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.success && result.data) {
|
||||
downloadExport(result.data);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle file selection
|
||||
const handleFileSelect = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
setImportFile(file);
|
||||
setParseError(null);
|
||||
|
||||
const result = await parseImportFile(file);
|
||||
if (result.success && result.data) {
|
||||
setImportData(result.data);
|
||||
} else {
|
||||
setParseError(result.error || 'Fehler beim Parsen');
|
||||
setImportData(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle import
|
||||
const handleImport = async () => {
|
||||
if (!importData) return;
|
||||
|
||||
let result;
|
||||
if (isGlobal) {
|
||||
result = await importGlobalRbac(importData, importMode);
|
||||
} else if (mandateId) {
|
||||
result = await importMandateRbac(mandateId, importData, importMode);
|
||||
} else {
|
||||
return;
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
setShowResult(true);
|
||||
// Clear import state
|
||||
setImportFile(null);
|
||||
setImportData(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Clear import state
|
||||
const handleClearImport = () => {
|
||||
setImportFile(null);
|
||||
setImportData(null);
|
||||
setParseError(null);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
// Handle close result
|
||||
const handleCloseResult = () => {
|
||||
setShowResult(false);
|
||||
reset();
|
||||
};
|
||||
|
||||
return (
|
||||
<div className={styles.rbacExportImport}>
|
||||
{/* Export Section */}
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<FaFileExport className={styles.sectionIcon} />
|
||||
<h3 className={styles.sectionTitle}>Export</h3>
|
||||
</div>
|
||||
<div className={styles.sectionContent}>
|
||||
<p className={styles.sectionDescription}>
|
||||
Exportiert alle Rollen und Berechtigungen
|
||||
{isGlobal ? ' der globalen Templates' : ` des Mandanten "${mandateName || mandateId}"`}
|
||||
{featureCode ? ` für Feature "${featureCode}"` : ''} als JSON-Datei.
|
||||
</p>
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={handleExport}
|
||||
disabled={exporting || (!isGlobal && !mandateId)}
|
||||
>
|
||||
{exporting ? (
|
||||
<>
|
||||
<FaSpinner className="spinning" /> Exportieren...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FaDownload /> RBAC exportieren
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Import Section */}
|
||||
<div className={styles.section}>
|
||||
<div className={styles.sectionHeader}>
|
||||
<FaFileImport className={styles.sectionIcon} />
|
||||
<h3 className={styles.sectionTitle}>Import</h3>
|
||||
</div>
|
||||
<div className={styles.sectionContent}>
|
||||
{/* File Upload */}
|
||||
<div className={styles.fileUpload}>
|
||||
<input
|
||||
type="file"
|
||||
ref={fileInputRef}
|
||||
accept=".json"
|
||||
onChange={handleFileSelect}
|
||||
className={styles.fileInput}
|
||||
id="rbac-import-file"
|
||||
/>
|
||||
<label htmlFor="rbac-import-file" className={styles.fileLabel}>
|
||||
{importFile ? (
|
||||
<>
|
||||
<FaCheckCircle className={styles.fileIcon} style={{ color: '#38a169' }} />
|
||||
<span>{importFile.name}</span>
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FaUpload className={styles.fileIcon} />
|
||||
<span>JSON-Datei auswählen oder hier ablegen</span>
|
||||
</>
|
||||
)}
|
||||
</label>
|
||||
{importFile && (
|
||||
<button
|
||||
className={styles.clearButton}
|
||||
onClick={handleClearImport}
|
||||
title="Datei entfernen"
|
||||
>
|
||||
<FaTrash />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Parse Error */}
|
||||
{parseError && (
|
||||
<div className={styles.errorMessage}>
|
||||
<FaExclamationTriangle /> {parseError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import Data Info */}
|
||||
{importData && (
|
||||
<div className={styles.importInfo}>
|
||||
<div className={styles.importStats}>
|
||||
<span><strong>Rollen:</strong> {importData.roles.length}</span>
|
||||
<span><strong>Regeln:</strong> {importData.accessRules.length}</span>
|
||||
<span><strong>Quelle:</strong> {importData.scope.type}</span>
|
||||
</div>
|
||||
<button
|
||||
className={styles.previewButton}
|
||||
onClick={() => setShowPreview(true)}
|
||||
>
|
||||
<FaEye /> Vorschau
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import Mode Selection */}
|
||||
{importData && (
|
||||
<div className={styles.importModeSection}>
|
||||
<h4 className={styles.importModeTitle}>Import-Modus</h4>
|
||||
<div className={styles.importModes}>
|
||||
{IMPORT_MODES.map(mode => (
|
||||
<label
|
||||
key={mode.value}
|
||||
className={`${styles.importModeOption} ${importMode === mode.value ? styles.selected : ''}`}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
name="importMode"
|
||||
value={mode.value}
|
||||
checked={importMode === mode.value}
|
||||
onChange={(e) => setImportMode(e.target.value as ImportMode)}
|
||||
className={styles.radioInput}
|
||||
/>
|
||||
<span className={styles.modeIcon}>{mode.icon}</span>
|
||||
<span className={styles.modeLabel}>{mode.label}</span>
|
||||
<span className={styles.modeDescription}>{mode.description}</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Import Button */}
|
||||
{importData && (
|
||||
<button
|
||||
className={`${styles.primaryButton} ${importMode === 'replace' ? styles.danger : ''}`}
|
||||
onClick={handleImport}
|
||||
disabled={importing || (!isGlobal && !mandateId)}
|
||||
>
|
||||
{importing ? (
|
||||
<>
|
||||
<FaSpinner className="spinning" /> Importieren...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<FaUpload /> RBAC importieren
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Warning for replace mode */}
|
||||
{importMode === 'replace' && importData && (
|
||||
<div className={styles.warningMessage}>
|
||||
<FaExclamationTriangle />
|
||||
<strong>Achtung:</strong> Im Modus "Ersetzen" werden alle bestehenden Rollen und Regeln gelöscht!
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error Message */}
|
||||
{error && (
|
||||
<div className={styles.errorMessage}>
|
||||
<FaExclamationTriangle /> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Preview Modal */}
|
||||
{showPreview && importData && (
|
||||
<div className={styles.modalOverlay} onClick={() => setShowPreview(false)}>
|
||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||||
<ExportPreview data={importData} onClose={() => setShowPreview(false)} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Result Modal */}
|
||||
{showResult && lastImportResult && (
|
||||
<div className={styles.modalOverlay} onClick={handleCloseResult}>
|
||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||||
<ImportResult result={lastImportResult} onClose={handleCloseResult} />
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default RbacExportImport;
|
||||
5
src/components/RbacExportImport/index.ts
Normal file
5
src/components/RbacExportImport/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
/**
|
||||
* RBAC Export/Import Components
|
||||
*/
|
||||
|
||||
export { RbacExportImport } from './RbacExportImport';
|
||||
248
src/hooks/useAccessRules.ts
Normal file
248
src/hooks/useAccessRules.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
/**
|
||||
* useAccessRules Hook
|
||||
*
|
||||
* Hook for managing RBAC AccessRules for a specific role.
|
||||
* Provides CRUD operations for access rules.
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import api from '../api';
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export type AccessLevel = 'n' | 'm' | 'g' | 'a';
|
||||
export type RuleContext = 'DATA' | 'UI' | 'RESOURCE';
|
||||
|
||||
export interface AccessRule {
|
||||
id: string;
|
||||
roleId: string;
|
||||
context: RuleContext;
|
||||
item: string | null;
|
||||
view: boolean;
|
||||
read: AccessLevel | null;
|
||||
create: AccessLevel | null;
|
||||
update: AccessLevel | null;
|
||||
delete: AccessLevel | null;
|
||||
}
|
||||
|
||||
export interface AccessRuleCreate {
|
||||
context: RuleContext;
|
||||
item: string | null;
|
||||
view?: boolean;
|
||||
read?: AccessLevel | null;
|
||||
create?: AccessLevel | null;
|
||||
update?: AccessLevel | null;
|
||||
delete?: AccessLevel | null;
|
||||
}
|
||||
|
||||
export interface AccessRuleUpdate {
|
||||
view?: boolean;
|
||||
read?: AccessLevel | null;
|
||||
create?: AccessLevel | null;
|
||||
update?: AccessLevel | null;
|
||||
delete?: AccessLevel | null;
|
||||
}
|
||||
|
||||
// Grouped rules by context
|
||||
export interface GroupedRules {
|
||||
DATA: AccessRule[];
|
||||
UI: AccessRule[];
|
||||
RESOURCE: AccessRule[];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// ACCESS LEVEL LABELS
|
||||
// =============================================================================
|
||||
|
||||
export const ACCESS_LEVEL_OPTIONS: { value: AccessLevel; label: string; color: string }[] = [
|
||||
{ value: 'n', label: 'Keine', color: '#e53e3e' },
|
||||
{ value: 'm', label: 'Eigene', color: '#d69e2e' },
|
||||
{ value: 'g', label: 'Gruppe', color: '#3182ce' },
|
||||
{ value: 'a', label: 'Alle', color: '#38a169' },
|
||||
];
|
||||
|
||||
export const getAccessLevelLabel = (level: AccessLevel | null): string => {
|
||||
if (!level) return '-';
|
||||
const option = ACCESS_LEVEL_OPTIONS.find(opt => opt.value === level);
|
||||
return option?.label || level;
|
||||
};
|
||||
|
||||
export const getAccessLevelColor = (level: AccessLevel | null): string => {
|
||||
if (!level) return '#718096';
|
||||
const option = ACCESS_LEVEL_OPTIONS.find(opt => opt.value === level);
|
||||
return option?.color || '#718096';
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// HOOK
|
||||
// =============================================================================
|
||||
|
||||
export function useAccessRules(roleId: string | null) {
|
||||
const [rules, setRules] = useState<AccessRule[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Fetch all rules for the role
|
||||
*/
|
||||
const fetchRules = useCallback(async (): Promise<AccessRule[]> => {
|
||||
if (!roleId) {
|
||||
setRules([]);
|
||||
return [];
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.get(`/api/rbac/roles/${roleId}/rules`);
|
||||
const fetchedRules = Array.isArray(response.data) ? response.data : response.data.rules || [];
|
||||
setRules(fetchedRules);
|
||||
return fetchedRules;
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to fetch access rules';
|
||||
setError(errorMessage);
|
||||
setRules([]);
|
||||
return [];
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [roleId]);
|
||||
|
||||
/**
|
||||
* Save all rules for the role (bulk update)
|
||||
*/
|
||||
const saveRules = useCallback(async (newRules: AccessRule[]): Promise<{ success: boolean; error?: string }> => {
|
||||
if (!roleId) {
|
||||
return { success: false, error: 'No role selected' };
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.put(`/api/rbac/roles/${roleId}/rules`, { rules: newRules });
|
||||
setRules(newRules);
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to save access rules';
|
||||
setError(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [roleId]);
|
||||
|
||||
/**
|
||||
* Create a new rule
|
||||
*/
|
||||
const createRule = useCallback(async (ruleData: AccessRuleCreate): Promise<{ success: boolean; data?: AccessRule; error?: string }> => {
|
||||
if (!roleId) {
|
||||
return { success: false, error: 'No role selected' };
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.post(`/api/rbac/roles/${roleId}/rules`, ruleData);
|
||||
const newRule = response.data;
|
||||
setRules(prev => [...prev, newRule]);
|
||||
return { success: true, data: newRule };
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to create access rule';
|
||||
setError(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [roleId]);
|
||||
|
||||
/**
|
||||
* Update an existing rule
|
||||
*/
|
||||
const updateRule = useCallback(async (ruleId: string, updates: AccessRuleUpdate): Promise<{ success: boolean; error?: string }> => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.patch(`/api/rbac/rules/${ruleId}`, updates);
|
||||
setRules(prev => prev.map(r => r.id === ruleId ? { ...r, ...response.data } : r));
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to update access rule';
|
||||
setError(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Delete a rule
|
||||
*/
|
||||
const deleteRule = useCallback(async (ruleId: string): Promise<{ success: boolean; error?: string }> => {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.delete(`/api/rbac/rules/${ruleId}`);
|
||||
setRules(prev => prev.filter(r => r.id !== ruleId));
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to delete access rule';
|
||||
setError(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get rules grouped by context
|
||||
*/
|
||||
const getGroupedRules = useCallback((): GroupedRules => {
|
||||
return {
|
||||
DATA: rules.filter(r => r.context === 'DATA'),
|
||||
UI: rules.filter(r => r.context === 'UI'),
|
||||
RESOURCE: rules.filter(r => r.context === 'RESOURCE'),
|
||||
};
|
||||
}, [rules]);
|
||||
|
||||
/**
|
||||
* Update rule locally (for optimistic updates)
|
||||
*/
|
||||
const updateRuleLocally = useCallback((ruleId: string, updates: Partial<AccessRule>) => {
|
||||
setRules(prev => prev.map(r => r.id === ruleId ? { ...r, ...updates } : r));
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Add rule locally (for optimistic updates)
|
||||
*/
|
||||
const addRuleLocally = useCallback((rule: AccessRule) => {
|
||||
setRules(prev => [...prev, rule]);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Remove rule locally (for optimistic updates)
|
||||
*/
|
||||
const removeRuleLocally = useCallback((ruleId: string) => {
|
||||
setRules(prev => prev.filter(r => r.id !== ruleId));
|
||||
}, []);
|
||||
|
||||
return {
|
||||
rules,
|
||||
loading,
|
||||
saving,
|
||||
error,
|
||||
fetchRules,
|
||||
saveRules,
|
||||
createRule,
|
||||
updateRule,
|
||||
deleteRule,
|
||||
getGroupedRules,
|
||||
updateRuleLocally,
|
||||
addRuleLocally,
|
||||
removeRuleLocally,
|
||||
};
|
||||
}
|
||||
|
||||
export default useAccessRules;
|
||||
249
src/hooks/useFeatureAccess.ts
Normal file
249
src/hooks/useFeatureAccess.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
/**
|
||||
* useFeatureAccess Hook
|
||||
*
|
||||
* Hook for managing feature instance access (which users can access which feature instances with which roles).
|
||||
* Uses the /api/features endpoints.
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import api from '../api';
|
||||
|
||||
// Types
|
||||
export interface Feature {
|
||||
code: string;
|
||||
label: string | { [key: string]: string };
|
||||
icon?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface FeatureInstance {
|
||||
id: string;
|
||||
featureCode: string;
|
||||
mandateId: string;
|
||||
label: string;
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface FeatureAccess {
|
||||
id: string;
|
||||
userId: string;
|
||||
featureInstanceId: string;
|
||||
enabled: boolean;
|
||||
roleIds?: string[];
|
||||
}
|
||||
|
||||
export interface FeatureAccessUser {
|
||||
userId: string;
|
||||
username: string;
|
||||
email?: string;
|
||||
fullName?: string;
|
||||
featureAccessId: string;
|
||||
roleIds: string[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface FeatureInstanceCreate {
|
||||
featureCode: string;
|
||||
label: string;
|
||||
copyTemplateRoles?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing feature access
|
||||
*/
|
||||
export function useFeatureAccess() {
|
||||
const [features, setFeatures] = useState<Feature[]>([]);
|
||||
const [instances, setInstances] = useState<FeatureInstance[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Fetch all available features
|
||||
*/
|
||||
const fetchFeatures = useCallback(async (): Promise<Feature[]> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.get('/api/features/');
|
||||
const data = Array.isArray(response.data) ? response.data : [];
|
||||
setFeatures(data);
|
||||
return data;
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to fetch features';
|
||||
setError(errorMessage);
|
||||
setFeatures([]);
|
||||
return [];
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Fetch feature instances for a mandate
|
||||
*/
|
||||
const fetchInstances = useCallback(async (mandateId: string, featureCode?: string): Promise<FeatureInstance[]> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
let url = '/api/features/instances';
|
||||
if (featureCode) {
|
||||
url += `?featureCode=${encodeURIComponent(featureCode)}`;
|
||||
}
|
||||
|
||||
const response = await api.get(url, {
|
||||
headers: {
|
||||
'X-Mandate-Id': mandateId
|
||||
}
|
||||
});
|
||||
const data = Array.isArray(response.data) ? response.data : [];
|
||||
setInstances(data);
|
||||
return data;
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to fetch feature instances';
|
||||
setError(errorMessage);
|
||||
setInstances([]);
|
||||
return [];
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Create a new feature instance
|
||||
*/
|
||||
const createInstance = useCallback(async (
|
||||
mandateId: string,
|
||||
data: FeatureInstanceCreate
|
||||
): Promise<{ success: boolean; data?: FeatureInstance; error?: string }> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.post('/api/features/instances', data, {
|
||||
headers: {
|
||||
'X-Mandate-Id': mandateId
|
||||
}
|
||||
});
|
||||
return { success: true, data: response.data };
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to create feature instance';
|
||||
setError(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Delete a feature instance
|
||||
*/
|
||||
const deleteInstance = useCallback(async (
|
||||
mandateId: string,
|
||||
instanceId: string
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.delete(`/api/features/instances/${instanceId}`, {
|
||||
headers: {
|
||||
'X-Mandate-Id': mandateId
|
||||
}
|
||||
});
|
||||
// Optimistically update the local state
|
||||
setInstances(prev => prev.filter(i => i.id !== instanceId));
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to delete feature instance';
|
||||
setError(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Sync roles for a feature instance from templates
|
||||
*/
|
||||
const syncInstanceRoles = useCallback(async (
|
||||
mandateId: string,
|
||||
instanceId: string,
|
||||
addOnly: boolean = true
|
||||
): Promise<{ success: boolean; data?: { added: number; removed: number; unchanged: number }; error?: string }> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.post(`/api/features/instances/${instanceId}/sync-roles?addOnly=${addOnly}`, {}, {
|
||||
headers: {
|
||||
'X-Mandate-Id': mandateId
|
||||
}
|
||||
});
|
||||
return { success: true, data: response.data };
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to sync instance roles';
|
||||
setError(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get current user's feature instances (grouped by mandate)
|
||||
*/
|
||||
const fetchMyFeatureInstances = useCallback(async (): Promise<{
|
||||
mandates: Array<{
|
||||
id: string;
|
||||
name: string;
|
||||
features: Array<{
|
||||
code: string;
|
||||
label: string | { [key: string]: string };
|
||||
instances: Array<{
|
||||
id: string;
|
||||
featureCode: string;
|
||||
mandateId: string;
|
||||
instanceLabel: string;
|
||||
}>;
|
||||
}>;
|
||||
}>;
|
||||
}> => {
|
||||
try {
|
||||
const response = await api.get('/api/features/my');
|
||||
return response.data || { mandates: [] };
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching my feature instances:', err);
|
||||
return { mandates: [] };
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get template roles for features
|
||||
*/
|
||||
const fetchTemplateRoles = useCallback(async (featureCode?: string): Promise<any[]> => {
|
||||
try {
|
||||
let url = '/api/features/templates/roles';
|
||||
if (featureCode) {
|
||||
url += `?featureCode=${encodeURIComponent(featureCode)}`;
|
||||
}
|
||||
const response = await api.get(url);
|
||||
return Array.isArray(response.data) ? response.data : [];
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching template roles:', err);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
features,
|
||||
instances,
|
||||
loading,
|
||||
error,
|
||||
fetchFeatures,
|
||||
fetchInstances,
|
||||
createInstance,
|
||||
deleteInstance,
|
||||
syncInstanceRoles,
|
||||
fetchMyFeatureInstances,
|
||||
fetchTemplateRoles,
|
||||
};
|
||||
}
|
||||
|
||||
export default useFeatureAccess;
|
||||
222
src/hooks/useInvitations.ts
Normal file
222
src/hooks/useInvitations.ts
Normal file
|
|
@ -0,0 +1,222 @@
|
|||
/**
|
||||
* useInvitations Hook
|
||||
*
|
||||
* Hook for managing invitations (creating, listing, validating, accepting).
|
||||
* Uses the /api/invitations endpoints.
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import api from '../api';
|
||||
|
||||
// Types
|
||||
export interface Invitation {
|
||||
id: string;
|
||||
token: string;
|
||||
mandateId: string;
|
||||
featureInstanceId?: string;
|
||||
roleIds: string[];
|
||||
email?: string;
|
||||
createdBy: string;
|
||||
createdAt: number;
|
||||
expiresAt: number;
|
||||
usedBy?: string;
|
||||
usedAt?: number;
|
||||
revokedAt?: number;
|
||||
maxUses: number;
|
||||
currentUses: number;
|
||||
inviteUrl: string;
|
||||
isExpired?: boolean;
|
||||
isUsedUp?: boolean;
|
||||
}
|
||||
|
||||
export interface InvitationCreate {
|
||||
email?: string;
|
||||
roleIds: string[];
|
||||
featureInstanceId?: string;
|
||||
expiresInHours?: number;
|
||||
maxUses?: number;
|
||||
}
|
||||
|
||||
export interface InvitationValidation {
|
||||
valid: boolean;
|
||||
reason?: string;
|
||||
mandateId?: string;
|
||||
mandateName?: string;
|
||||
featureInstanceId?: string;
|
||||
roleIds: string[];
|
||||
roleLabels?: string[];
|
||||
}
|
||||
|
||||
export interface RegisterAndAcceptData {
|
||||
token: string;
|
||||
username: string;
|
||||
email: string;
|
||||
password: string;
|
||||
firstname?: string;
|
||||
lastname?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing invitations
|
||||
*/
|
||||
export function useInvitations() {
|
||||
const [invitations, setInvitations] = useState<Invitation[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Fetch all invitations for a mandate
|
||||
*/
|
||||
const fetchInvitations = useCallback(async (
|
||||
mandateId: string,
|
||||
options?: { includeUsed?: boolean; includeExpired?: boolean }
|
||||
): Promise<Invitation[]> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (options?.includeUsed) params.append('includeUsed', 'true');
|
||||
if (options?.includeExpired) params.append('includeExpired', 'true');
|
||||
|
||||
const response = await api.get(`/api/invitations/?${params.toString()}`, {
|
||||
headers: { 'X-Mandate-Id': mandateId }
|
||||
});
|
||||
const data = Array.isArray(response.data) ? response.data : [];
|
||||
setInvitations(data);
|
||||
return data;
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to fetch invitations';
|
||||
setError(errorMessage);
|
||||
setInvitations([]);
|
||||
return [];
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Create a new invitation
|
||||
*/
|
||||
const createInvitation = useCallback(async (
|
||||
mandateId: string,
|
||||
data: InvitationCreate
|
||||
): Promise<{ success: boolean; data?: Invitation; error?: string }> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.post('/api/invitations/', data, {
|
||||
headers: { 'X-Mandate-Id': mandateId }
|
||||
});
|
||||
return { success: true, data: response.data };
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to create invitation';
|
||||
setError(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Revoke an invitation
|
||||
*/
|
||||
const revokeInvitation = useCallback(async (
|
||||
mandateId: string,
|
||||
invitationId: string
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.delete(`/api/invitations/${invitationId}`, {
|
||||
headers: { 'X-Mandate-Id': mandateId }
|
||||
});
|
||||
// Optimistically update local state
|
||||
setInvitations(prev => prev.filter(inv => inv.id !== invitationId));
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to revoke invitation';
|
||||
setError(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Validate an invitation token (public - no auth required)
|
||||
*/
|
||||
const validateInvitation = useCallback(async (
|
||||
token: string
|
||||
): Promise<InvitationValidation> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.get(`/api/invitations/validate/${token}`);
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to validate invitation';
|
||||
setError(errorMessage);
|
||||
return {
|
||||
valid: false,
|
||||
reason: errorMessage,
|
||||
roleIds: []
|
||||
};
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Accept an invitation (requires authentication)
|
||||
*/
|
||||
const acceptInvitation = useCallback(async (
|
||||
token: string
|
||||
): Promise<{ success: boolean; data?: any; error?: string }> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.post(`/api/invitations/accept/${token}`);
|
||||
return { success: true, data: response.data };
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to accept invitation';
|
||||
setError(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Register and accept invitation in one step (public - no auth required)
|
||||
*/
|
||||
const registerAndAccept = useCallback(async (
|
||||
data: RegisterAndAcceptData
|
||||
): Promise<{ success: boolean; data?: any; error?: string }> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.post('/api/invitations/register-and-accept', data);
|
||||
return { success: true, data: response.data };
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to register and accept invitation';
|
||||
setError(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
invitations,
|
||||
loading,
|
||||
error,
|
||||
fetchInvitations,
|
||||
createInvitation,
|
||||
revokeInvitation,
|
||||
validateInvitation,
|
||||
acceptInvitation,
|
||||
registerAndAccept,
|
||||
};
|
||||
}
|
||||
|
||||
export default useInvitations;
|
||||
242
src/hooks/useMandateRoles.ts
Normal file
242
src/hooks/useMandateRoles.ts
Normal file
|
|
@ -0,0 +1,242 @@
|
|||
/**
|
||||
* useMandateRoles Hook
|
||||
*
|
||||
* Hook for managing roles within a specific mandate.
|
||||
* Uses the /api/rbac/roles endpoints.
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import api from '../api';
|
||||
|
||||
// Types
|
||||
export interface Role {
|
||||
id: string;
|
||||
roleLabel: string;
|
||||
description?: string | { [key: string]: string };
|
||||
mandateId?: string;
|
||||
featureInstanceId?: string;
|
||||
featureCode?: string;
|
||||
isSystemRole?: boolean;
|
||||
isTemplate?: boolean;
|
||||
createdAt?: number;
|
||||
updatedAt?: number;
|
||||
}
|
||||
|
||||
export interface RoleCreate {
|
||||
roleLabel: string;
|
||||
description?: string | { [key: string]: string };
|
||||
mandateId?: string;
|
||||
featureInstanceId?: string;
|
||||
featureCode?: string;
|
||||
}
|
||||
|
||||
export interface RoleUpdate {
|
||||
roleLabel?: string;
|
||||
description?: string | { [key: string]: string };
|
||||
mandateId?: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing mandate roles
|
||||
*/
|
||||
export function useMandateRoles() {
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Fetch all roles (optionally filtered by mandate)
|
||||
*/
|
||||
const fetchRoles = useCallback(async (mandateId?: string): Promise<Role[]> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (mandateId) {
|
||||
headers['X-Mandate-Id'] = mandateId;
|
||||
}
|
||||
|
||||
const response = await api.get('/api/rbac/roles', { headers });
|
||||
let data: Role[] = [];
|
||||
|
||||
if (response.data?.items && Array.isArray(response.data.items)) {
|
||||
data = response.data.items;
|
||||
} else if (Array.isArray(response.data)) {
|
||||
data = response.data;
|
||||
}
|
||||
|
||||
// Filter to only show roles for this mandate (or global roles)
|
||||
if (mandateId) {
|
||||
data = data.filter(r => !r.mandateId || r.mandateId === mandateId);
|
||||
}
|
||||
|
||||
setRoles(data);
|
||||
return data;
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to fetch roles';
|
||||
setError(errorMessage);
|
||||
setRoles([]);
|
||||
return [];
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Get a single role by ID
|
||||
*/
|
||||
const getRole = useCallback(async (roleId: string): Promise<Role | null> => {
|
||||
try {
|
||||
const response = await api.get(`/api/rbac/roles/${roleId}`);
|
||||
return response.data;
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching role:', err);
|
||||
return null;
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Create a new role
|
||||
*/
|
||||
const createRole = useCallback(async (
|
||||
data: RoleCreate,
|
||||
mandateId?: string
|
||||
): Promise<{ success: boolean; data?: Role; error?: string }> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const headers: Record<string, string> = {};
|
||||
if (mandateId) {
|
||||
headers['X-Mandate-Id'] = mandateId;
|
||||
}
|
||||
|
||||
const response = await api.post('/api/rbac/roles', data, { headers });
|
||||
return { success: true, data: response.data };
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to create role';
|
||||
setError(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Update an existing role
|
||||
*/
|
||||
const updateRole = useCallback(async (
|
||||
roleId: string,
|
||||
data: RoleUpdate
|
||||
): Promise<{ success: boolean; data?: Role; error?: string }> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.put(`/api/rbac/roles/${roleId}`, data);
|
||||
// Optimistically update local state
|
||||
setRoles(prev => prev.map(r => r.id === roleId ? { ...r, ...data } : r));
|
||||
return { success: true, data: response.data };
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to update role';
|
||||
setError(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Delete a role
|
||||
*/
|
||||
const deleteRole = useCallback(async (
|
||||
roleId: string
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.delete(`/api/rbac/roles/${roleId}`);
|
||||
// Optimistically update local state
|
||||
setRoles(prev => prev.filter(r => r.id !== roleId));
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to delete role';
|
||||
setError(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* 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
|
||||
*/
|
||||
const getUsersWithRole = useCallback(async (
|
||||
roleLabel: string
|
||||
): Promise<Array<{ userId: string; username: string; email?: string }>> => {
|
||||
try {
|
||||
const response = await api.get(`/api/rbac/roles/roles/${roleLabel}/users`);
|
||||
return Array.isArray(response.data) ? response.data : [];
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching users with role:', err);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Filter roles by type
|
||||
*/
|
||||
const getMandateRoles = useCallback((mandateId: string) => {
|
||||
return roles.filter(r =>
|
||||
r.mandateId === mandateId && !r.featureInstanceId
|
||||
);
|
||||
}, [roles]);
|
||||
|
||||
const getFeatureRoles = useCallback((featureInstanceId: string) => {
|
||||
return roles.filter(r => r.featureInstanceId === featureInstanceId);
|
||||
}, [roles]);
|
||||
|
||||
const getGlobalRoles = useCallback(() => {
|
||||
return roles.filter(r => !r.mandateId && !r.featureInstanceId);
|
||||
}, [roles]);
|
||||
|
||||
const getTemplateRoles = useCallback(() => {
|
||||
return roles.filter(r => r.isTemplate === true);
|
||||
}, [roles]);
|
||||
|
||||
return {
|
||||
roles,
|
||||
loading,
|
||||
error,
|
||||
fetchRoles,
|
||||
getRole,
|
||||
createRole,
|
||||
updateRole,
|
||||
deleteRole,
|
||||
fetchRoleOptions,
|
||||
getUsersWithRole,
|
||||
getMandateRoles,
|
||||
getFeatureRoles,
|
||||
getGlobalRoles,
|
||||
getTemplateRoles,
|
||||
};
|
||||
}
|
||||
|
||||
export default useMandateRoles;
|
||||
|
|
@ -148,7 +148,7 @@ export function useAdminMandates() {
|
|||
return await fetchMandateByIdApi(request, mandateId);
|
||||
}, [request]);
|
||||
|
||||
// Generate columns from attributes
|
||||
// Generate columns from attributes (including fkSource/fkDisplayField for FK resolution)
|
||||
const columns = attributes.map(attr => ({
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
|
|
@ -159,6 +159,8 @@ export function useAdminMandates() {
|
|||
width: attr.width || 150,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
fkSource: (attr as any).fkSource, // API endpoint for FK data
|
||||
fkDisplayField: (attr as any).fkDisplayField, // Which field of FK target to display
|
||||
}));
|
||||
|
||||
// Create mandate
|
||||
|
|
|
|||
270
src/hooks/useRbacExportImport.ts
Normal file
270
src/hooks/useRbacExportImport.ts
Normal file
|
|
@ -0,0 +1,270 @@
|
|||
/**
|
||||
* useRbacExportImport Hook
|
||||
*
|
||||
* Hook for exporting and importing RBAC configurations.
|
||||
* Supports mandate-level and global (template) exports.
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import api from '../api';
|
||||
|
||||
// =============================================================================
|
||||
// TYPES
|
||||
// =============================================================================
|
||||
|
||||
export type ImportMode = 'merge' | 'replace' | 'add_only';
|
||||
|
||||
export interface RbacExportScope {
|
||||
type: 'global' | 'mandate' | 'instance';
|
||||
mandateId?: string;
|
||||
mandateName?: string;
|
||||
featureInstanceId?: string;
|
||||
featureCode?: string;
|
||||
instanceLabel?: string;
|
||||
}
|
||||
|
||||
export interface RbacExportRole {
|
||||
roleLabel: string;
|
||||
description?: { [key: string]: string };
|
||||
featureCode?: string;
|
||||
}
|
||||
|
||||
export interface RbacExportRule {
|
||||
roleLabel: string;
|
||||
context: 'DATA' | 'UI' | 'RESOURCE';
|
||||
item: string | null;
|
||||
view: boolean;
|
||||
read?: string | null;
|
||||
create?: string | null;
|
||||
update?: string | null;
|
||||
delete?: string | null;
|
||||
}
|
||||
|
||||
export interface RbacExport {
|
||||
version: string;
|
||||
exportedAt: string;
|
||||
exportedBy?: string;
|
||||
scope: RbacExportScope;
|
||||
roles: RbacExportRole[];
|
||||
accessRules: RbacExportRule[];
|
||||
}
|
||||
|
||||
export interface RbacImportResult {
|
||||
status: 'success' | 'error';
|
||||
mode: ImportMode;
|
||||
rolesCreated: number;
|
||||
rolesUpdated: number;
|
||||
rulesCreated: number;
|
||||
rulesUpdated: number;
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// HOOK
|
||||
// =============================================================================
|
||||
|
||||
export function useRbacExportImport() {
|
||||
const [exporting, setExporting] = useState(false);
|
||||
const [importing, setImporting] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [lastExport, setLastExport] = useState<RbacExport | null>(null);
|
||||
const [lastImportResult, setLastImportResult] = useState<RbacImportResult | null>(null);
|
||||
|
||||
/**
|
||||
* Export RBAC configuration for a mandate
|
||||
*/
|
||||
const exportMandateRbac = useCallback(async (
|
||||
mandateId: string,
|
||||
featureCode?: string
|
||||
): Promise<{ success: boolean; data?: RbacExport; error?: string }> => {
|
||||
setExporting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (featureCode) params.append('featureCode', featureCode);
|
||||
|
||||
const url = `/api/mandates/${mandateId}/rbac/export${params.toString() ? '?' + params.toString() : ''}`;
|
||||
const response = await api.get(url);
|
||||
|
||||
setLastExport(response.data);
|
||||
return { success: true, data: response.data };
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to export RBAC';
|
||||
setError(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Export global RBAC templates (SysAdmin only)
|
||||
*/
|
||||
const exportGlobalRbac = useCallback(async (
|
||||
featureCode?: string
|
||||
): Promise<{ success: boolean; data?: RbacExport; error?: string }> => {
|
||||
setExporting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const params = new URLSearchParams();
|
||||
if (featureCode) params.append('featureCode', featureCode);
|
||||
|
||||
const url = `/api/admin/rbac/global/export${params.toString() ? '?' + params.toString() : ''}`;
|
||||
const response = await api.get(url);
|
||||
|
||||
setLastExport(response.data);
|
||||
return { success: true, data: response.data };
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to export global RBAC';
|
||||
setError(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Export feature instance RBAC
|
||||
*/
|
||||
const exportInstanceRbac = useCallback(async (
|
||||
instanceId: string
|
||||
): Promise<{ success: boolean; data?: RbacExport; error?: string }> => {
|
||||
setExporting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.get(`/api/features/instances/${instanceId}/rbac/export`);
|
||||
|
||||
setLastExport(response.data);
|
||||
return { success: true, data: response.data };
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to export instance RBAC';
|
||||
setError(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setExporting(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Import RBAC configuration into a mandate
|
||||
*/
|
||||
const importMandateRbac = useCallback(async (
|
||||
mandateId: string,
|
||||
data: RbacExport,
|
||||
mode: ImportMode = 'merge'
|
||||
): Promise<{ success: boolean; result?: RbacImportResult; error?: string }> => {
|
||||
setImporting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.post(
|
||||
`/api/mandates/${mandateId}/rbac/import?mode=${mode}`,
|
||||
data
|
||||
);
|
||||
|
||||
setLastImportResult(response.data);
|
||||
return { success: true, result: response.data };
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to import RBAC';
|
||||
setError(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Import global RBAC templates (SysAdmin only)
|
||||
*/
|
||||
const importGlobalRbac = useCallback(async (
|
||||
data: RbacExport,
|
||||
mode: ImportMode = 'merge'
|
||||
): Promise<{ success: boolean; result?: RbacImportResult; error?: string }> => {
|
||||
setImporting(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.post(
|
||||
`/api/admin/rbac/global/import?mode=${mode}`,
|
||||
data
|
||||
);
|
||||
|
||||
setLastImportResult(response.data);
|
||||
return { success: true, result: response.data };
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to import global RBAC';
|
||||
setError(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setImporting(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Download export as JSON file
|
||||
*/
|
||||
const downloadExport = useCallback((data: RbacExport, filename?: string) => {
|
||||
const blob = new Blob([JSON.stringify(data, null, 2)], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = filename || `rbac-export-${data.scope.type}-${new Date().toISOString().split('T')[0]}.json`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
URL.revokeObjectURL(url);
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Parse uploaded JSON file
|
||||
*/
|
||||
const parseImportFile = useCallback(async (file: File): Promise<{ success: boolean; data?: RbacExport; error?: string }> => {
|
||||
try {
|
||||
const text = await file.text();
|
||||
const data = JSON.parse(text) as RbacExport;
|
||||
|
||||
// Basic validation
|
||||
if (!data.version) {
|
||||
return { success: false, error: 'Ungültiges Format: Fehlende Version' };
|
||||
}
|
||||
if (!data.scope) {
|
||||
return { success: false, error: 'Ungültiges Format: Fehlender Scope' };
|
||||
}
|
||||
if (!Array.isArray(data.roles)) {
|
||||
return { success: false, error: 'Ungültiges Format: Roles muss ein Array sein' };
|
||||
}
|
||||
if (!Array.isArray(data.accessRules)) {
|
||||
return { success: false, error: 'Ungültiges Format: AccessRules muss ein Array sein' };
|
||||
}
|
||||
|
||||
return { success: true, data };
|
||||
} catch (err: any) {
|
||||
return { success: false, error: `Fehler beim Parsen: ${err.message}` };
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Clear state
|
||||
*/
|
||||
const reset = useCallback(() => {
|
||||
setError(null);
|
||||
setLastExport(null);
|
||||
setLastImportResult(null);
|
||||
}, []);
|
||||
|
||||
return {
|
||||
exporting,
|
||||
importing,
|
||||
error,
|
||||
lastExport,
|
||||
lastImportResult,
|
||||
exportMandateRbac,
|
||||
exportGlobalRbac,
|
||||
exportInstanceRbac,
|
||||
importMandateRbac,
|
||||
importGlobalRbac,
|
||||
downloadExport,
|
||||
parseImportFile,
|
||||
reset,
|
||||
};
|
||||
}
|
||||
|
||||
export default useRbacExportImport;
|
||||
|
|
@ -148,7 +148,7 @@ export function useAdminRoles() {
|
|||
return await fetchRoleByIdApi(request, roleId);
|
||||
}, [request]);
|
||||
|
||||
// Generate columns from attributes
|
||||
// Generate columns from attributes (including fkSource/fkDisplayField for FK resolution)
|
||||
const columns = attributes.map(attr => ({
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
|
|
@ -159,6 +159,8 @@ export function useAdminRoles() {
|
|||
width: attr.width || 150,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
fkSource: (attr as any).fkSource, // API endpoint for FK data
|
||||
fkDisplayField: (attr as any).fkDisplayField, // Which field of FK target to display
|
||||
}));
|
||||
|
||||
// Create role
|
||||
|
|
|
|||
223
src/hooks/useUserMandates.ts
Normal file
223
src/hooks/useUserMandates.ts
Normal file
|
|
@ -0,0 +1,223 @@
|
|||
/**
|
||||
* useUserMandates Hook
|
||||
*
|
||||
* Hook for managing user-mandate memberships (which users belong to which mandates with which roles).
|
||||
* Uses the /api/mandates/{mandateId}/users endpoints.
|
||||
*/
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import api from '../api';
|
||||
|
||||
// Types
|
||||
export interface MandateUser {
|
||||
userId: string;
|
||||
username: string;
|
||||
email: string | null;
|
||||
firstname: string | null;
|
||||
lastname: string | null;
|
||||
userMandateId: string;
|
||||
roleIds: string[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface UserMandateCreate {
|
||||
targetUserId: string;
|
||||
roleIds: string[];
|
||||
}
|
||||
|
||||
export interface UserMandateResponse {
|
||||
userMandateId: string;
|
||||
userId: string;
|
||||
mandateId: string;
|
||||
roleIds: string[];
|
||||
enabled: boolean;
|
||||
}
|
||||
|
||||
export interface Role {
|
||||
id: string;
|
||||
roleLabel: string;
|
||||
description?: string | { [key: string]: string };
|
||||
mandateId?: string;
|
||||
featureInstanceId?: string;
|
||||
isSystemRole?: boolean;
|
||||
}
|
||||
|
||||
export interface Mandate {
|
||||
id: string;
|
||||
name: string | { [key: string]: string };
|
||||
code?: string;
|
||||
language?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook for managing user-mandate memberships
|
||||
*/
|
||||
export function useUserMandates() {
|
||||
const [users, setUsers] = useState<MandateUser[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
/**
|
||||
* Fetch all users in a specific mandate
|
||||
*/
|
||||
const fetchMandateUsers = useCallback(async (mandateId: string): Promise<MandateUser[]> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.get(`/api/mandates/${mandateId}/users`);
|
||||
const data = Array.isArray(response.data) ? response.data : [];
|
||||
setUsers(data);
|
||||
return data;
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to fetch mandate users';
|
||||
setError(errorMessage);
|
||||
setUsers([]);
|
||||
return [];
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Add a user to a mandate with specified roles
|
||||
*/
|
||||
const addUserToMandate = useCallback(async (
|
||||
mandateId: string,
|
||||
data: UserMandateCreate
|
||||
): Promise<{ success: boolean; data?: UserMandateResponse; error?: string }> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.post(`/api/mandates/${mandateId}/users`, data);
|
||||
return { success: true, data: response.data };
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to add user to mandate';
|
||||
setError(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Remove a user from a mandate
|
||||
*/
|
||||
const removeUserFromMandate = useCallback(async (
|
||||
mandateId: string,
|
||||
userId: string
|
||||
): Promise<{ success: boolean; error?: string }> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await api.delete(`/api/mandates/${mandateId}/users/${userId}`);
|
||||
// Optimistically update the local state
|
||||
setUsers(prev => prev.filter(u => u.userId !== userId));
|
||||
return { success: true };
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to remove user from mandate';
|
||||
setError(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Update a user's roles within a mandate
|
||||
*/
|
||||
const updateUserRoles = useCallback(async (
|
||||
mandateId: string,
|
||||
userId: string,
|
||||
roleIds: string[]
|
||||
): Promise<{ success: boolean; data?: UserMandateResponse; error?: string }> => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const response = await api.put(`/api/mandates/${mandateId}/users/${userId}/roles`, roleIds);
|
||||
// Optimistically update the local state
|
||||
setUsers(prev => prev.map(u =>
|
||||
u.userId === userId ? { ...u, roleIds } : u
|
||||
));
|
||||
return { success: true, data: response.data };
|
||||
} catch (err: any) {
|
||||
const errorMessage = err.response?.data?.detail || err.message || 'Failed to update user roles';
|
||||
setError(errorMessage);
|
||||
return { success: false, error: errorMessage };
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Fetch all available mandates (for selection)
|
||||
*/
|
||||
const fetchMandates = useCallback(async (): Promise<Mandate[]> => {
|
||||
try {
|
||||
const response = await api.get('/api/mandates/');
|
||||
if (response.data?.items && Array.isArray(response.data.items)) {
|
||||
return response.data.items;
|
||||
}
|
||||
return Array.isArray(response.data) ? response.data : [];
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching mandates:', err);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Fetch all available roles (global and mandate-specific)
|
||||
*/
|
||||
const fetchRoles = useCallback(async (mandateId?: string): Promise<Role[]> => {
|
||||
try {
|
||||
const response = await api.get('/api/rbac/roles');
|
||||
let roles: Role[] = [];
|
||||
if (response.data?.items && Array.isArray(response.data.items)) {
|
||||
roles = response.data.items;
|
||||
} else if (Array.isArray(response.data)) {
|
||||
roles = response.data;
|
||||
}
|
||||
|
||||
// Filter to global roles and roles for this mandate
|
||||
if (mandateId) {
|
||||
return roles.filter(r =>
|
||||
!r.mandateId || r.mandateId === mandateId
|
||||
);
|
||||
}
|
||||
return roles;
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching roles:', err);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
/**
|
||||
* Fetch all users (for selection when adding to mandate)
|
||||
*/
|
||||
const fetchAllUsers = useCallback(async (): Promise<Array<{id: string; username: string; email?: string; fullName?: string}>> => {
|
||||
try {
|
||||
const response = await api.get('/api/users/');
|
||||
if (response.data?.items && Array.isArray(response.data.items)) {
|
||||
return response.data.items;
|
||||
}
|
||||
return Array.isArray(response.data) ? response.data : [];
|
||||
} catch (err: any) {
|
||||
console.error('Error fetching users:', err);
|
||||
return [];
|
||||
}
|
||||
}, []);
|
||||
|
||||
return {
|
||||
users,
|
||||
loading,
|
||||
error,
|
||||
fetchMandateUsers,
|
||||
addUserToMandate,
|
||||
removeUserFromMandate,
|
||||
updateUserRoles,
|
||||
fetchMandates,
|
||||
fetchRoles,
|
||||
fetchAllUsers,
|
||||
};
|
||||
}
|
||||
|
||||
export default useUserMandates;
|
||||
316
src/pages/InvitePage.module.css
Normal file
316
src/pages/InvitePage.module.css
Normal file
|
|
@ -0,0 +1,316 @@
|
|||
/**
|
||||
* InvitePage Styles
|
||||
*/
|
||||
|
||||
.container {
|
||||
min-height: 100vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 2rem;
|
||||
background: var(--bg-secondary);
|
||||
}
|
||||
|
||||
.card {
|
||||
background: var(--surface-color);
|
||||
border-radius: 12px;
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.1);
|
||||
padding: 2rem;
|
||||
width: 100%;
|
||||
max-width: 480px;
|
||||
}
|
||||
|
||||
.header {
|
||||
text-align: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.header h1 {
|
||||
font-size: 1.5rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.header p {
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.loading {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 3rem 2rem;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.loading p {
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.spinner {
|
||||
animation: spin 1s linear infinite;
|
||||
font-size: 1.25rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Error State */
|
||||
.errorState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.errorIcon {
|
||||
font-size: 3rem;
|
||||
color: var(--danger-color, #e53e3e);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.errorState h1 {
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.errorState p {
|
||||
color: var(--text-secondary);
|
||||
margin: 0 0 1.5rem 0;
|
||||
}
|
||||
|
||||
/* Success State */
|
||||
.successState {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
padding: 2rem 1rem;
|
||||
}
|
||||
|
||||
.successIcon {
|
||||
font-size: 3rem;
|
||||
color: var(--success-color, #38a169);
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.successState h1 {
|
||||
font-size: 1.25rem;
|
||||
color: var(--text-primary);
|
||||
margin: 0 0 0.5rem 0;
|
||||
}
|
||||
|
||||
.successState p {
|
||||
color: var(--text-secondary);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Invite Info */
|
||||
.inviteInfo {
|
||||
background: var(--bg-secondary);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.infoRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
}
|
||||
|
||||
.infoRow:not(:last-child) {
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.infoLabel {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.infoValue {
|
||||
color: var(--text-primary);
|
||||
font-weight: 500;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Error Message */
|
||||
.errorMessage {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: rgba(229, 62, 62, 0.1);
|
||||
color: var(--danger-color, #e53e3e);
|
||||
border-radius: 8px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Form */
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.formRow {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1rem;
|
||||
}
|
||||
|
||||
.formGroup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
|
||||
.formGroup label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.formGroup label svg {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.75rem;
|
||||
}
|
||||
|
||||
.formGroup input {
|
||||
padding: 0.625rem 0.75rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
transition: border-color 0.2s, box-shadow 0.2s;
|
||||
}
|
||||
|
||||
.formGroup input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color, #f25843);
|
||||
box-shadow: 0 0 0 3px rgba(242, 88, 67, 0.1);
|
||||
}
|
||||
|
||||
.formGroup input::placeholder {
|
||||
color: var(--text-tertiary);
|
||||
}
|
||||
|
||||
/* Actions */
|
||||
.actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
|
||||
.primaryButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: var(--primary-color, #f25843);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.primaryButton:hover {
|
||||
background: var(--primary-dark, #d94d3a);
|
||||
}
|
||||
|
||||
.primaryButton:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.secondaryButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1.5rem;
|
||||
background: transparent;
|
||||
color: var(--text-primary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 8px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s, border-color 0.2s;
|
||||
text-decoration: none;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.secondaryButton:hover {
|
||||
background: var(--bg-secondary);
|
||||
border-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Divider */
|
||||
.divider {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
margin: 1.5rem 0;
|
||||
}
|
||||
|
||||
.divider::before,
|
||||
.divider::after {
|
||||
content: '';
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--border-color);
|
||||
}
|
||||
|
||||
.divider span {
|
||||
padding: 0 1rem;
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* Login Option */
|
||||
.loginOption {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.loginOption p {
|
||||
color: var(--text-secondary);
|
||||
font-size: 0.875rem;
|
||||
margin: 0 0 0.75rem 0;
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 500px) {
|
||||
.card {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
.formRow {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
}
|
||||
|
||||
/* Dark theme */
|
||||
:global(.dark-theme) .card {
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
363
src/pages/InvitePage.tsx
Normal file
363
src/pages/InvitePage.tsx
Normal file
|
|
@ -0,0 +1,363 @@
|
|||
/**
|
||||
* InvitePage
|
||||
*
|
||||
* Public page for accepting invitations.
|
||||
* URL: /invite/:token
|
||||
*
|
||||
* Handles both:
|
||||
* - Existing users (shows login or auto-accepts if already logged in)
|
||||
* - New users (shows registration form)
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||
import { useInvitations, type InvitationValidation, type RegisterAndAcceptData } from '../hooks/useInvitations';
|
||||
import { useAuth } from '../hooks/useAuthentication';
|
||||
import { FaCheckCircle, FaTimesCircle, FaSpinner, FaEnvelope, FaUser, FaLock } from 'react-icons/fa';
|
||||
import styles from './InvitePage.module.css';
|
||||
|
||||
export const InvitePage: React.FC = () => {
|
||||
const { token } = useParams<{ token: string }>();
|
||||
const navigate = useNavigate();
|
||||
const { isAuthenticated, user } = useAuth();
|
||||
const { validateInvitation, acceptInvitation, registerAndAccept, loading } = useInvitations();
|
||||
|
||||
// State
|
||||
const [validation, setValidation] = useState<InvitationValidation | null>(null);
|
||||
const [validating, setValidating] = useState(true);
|
||||
const [accepting, setAccepting] = useState(false);
|
||||
const [success, setSuccess] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Registration form state
|
||||
const [formData, setFormData] = useState<RegisterAndAcceptData>({
|
||||
token: token || '',
|
||||
username: '',
|
||||
email: '',
|
||||
password: '',
|
||||
firstname: '',
|
||||
lastname: '',
|
||||
});
|
||||
const [confirmPassword, setConfirmPassword] = useState('');
|
||||
|
||||
// Validate token on mount
|
||||
useEffect(() => {
|
||||
const validate = async () => {
|
||||
if (!token) {
|
||||
setError('Kein Einladungs-Token angegeben');
|
||||
setValidating(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const result = await validateInvitation(token);
|
||||
setValidation(result);
|
||||
setValidating(false);
|
||||
|
||||
// Update form with token
|
||||
setFormData(prev => ({ ...prev, token }));
|
||||
};
|
||||
|
||||
validate();
|
||||
}, [token, validateInvitation]);
|
||||
|
||||
// Auto-accept if already logged in
|
||||
const handleAccept = async () => {
|
||||
if (!token) return;
|
||||
|
||||
setAccepting(true);
|
||||
setError(null);
|
||||
|
||||
const result = await acceptInvitation(token);
|
||||
|
||||
if (result.success) {
|
||||
setSuccess(true);
|
||||
// Redirect to dashboard after 2 seconds
|
||||
setTimeout(() => {
|
||||
navigate('/');
|
||||
}, 2000);
|
||||
} else {
|
||||
setError(result.error || 'Fehler beim Annehmen der Einladung');
|
||||
}
|
||||
|
||||
setAccepting(false);
|
||||
};
|
||||
|
||||
// Handle registration form submission
|
||||
const handleRegister = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
|
||||
// Validate passwords match
|
||||
if (formData.password !== confirmPassword) {
|
||||
setError('Die Passwörter stimmen nicht überein');
|
||||
return;
|
||||
}
|
||||
|
||||
// Validate password length
|
||||
if (formData.password.length < 8) {
|
||||
setError('Das Passwort muss mindestens 8 Zeichen lang sein');
|
||||
return;
|
||||
}
|
||||
|
||||
setAccepting(true);
|
||||
|
||||
const result = await registerAndAccept(formData);
|
||||
|
||||
if (result.success) {
|
||||
setSuccess(true);
|
||||
// Redirect to login after 3 seconds
|
||||
setTimeout(() => {
|
||||
navigate('/login');
|
||||
}, 3000);
|
||||
} else {
|
||||
setError(result.error || 'Fehler bei der Registrierung');
|
||||
}
|
||||
|
||||
setAccepting(false);
|
||||
};
|
||||
|
||||
// Handle form field changes
|
||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const { name, value } = e.target;
|
||||
setFormData(prev => ({ ...prev, [name]: value }));
|
||||
};
|
||||
|
||||
// Loading state
|
||||
if (validating) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.loading}>
|
||||
<FaSpinner className={styles.spinner} />
|
||||
<p>Einladung wird überprüft...</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Invalid invitation
|
||||
if (!validation?.valid) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.errorState}>
|
||||
<FaTimesCircle className={styles.errorIcon} />
|
||||
<h1>Ungültige Einladung</h1>
|
||||
<p>{validation?.reason || 'Diese Einladung ist nicht gültig.'}</p>
|
||||
<Link to="/login" className={styles.primaryButton}>
|
||||
Zur Anmeldung
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Success state
|
||||
if (success) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.successState}>
|
||||
<FaCheckCircle className={styles.successIcon} />
|
||||
<h1>Erfolgreich!</h1>
|
||||
<p>
|
||||
{isAuthenticated
|
||||
? 'Sie wurden erfolgreich zum Mandanten hinzugefügt.'
|
||||
: 'Ihr Konto wurde erstellt. Sie werden zur Anmeldeseite weitergeleitet...'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Already authenticated - show accept button
|
||||
if (isAuthenticated) {
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.header}>
|
||||
<h1>Einladung annehmen</h1>
|
||||
<p>Sie wurden eingeladen, einem Mandanten beizutreten.</p>
|
||||
</div>
|
||||
|
||||
<div className={styles.inviteInfo}>
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>Angemeldet als:</span>
|
||||
<span className={styles.infoValue}>{user?.email || user?.username}</span>
|
||||
</div>
|
||||
{validation.roleLabels && validation.roleLabels.length > 0 && (
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>Zugewiesene Rollen:</span>
|
||||
<span className={styles.infoValue}>{validation.roleLabels.join(', ')}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={styles.errorMessage}>
|
||||
<FaTimesCircle /> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={handleAccept}
|
||||
disabled={accepting}
|
||||
>
|
||||
{accepting ? (
|
||||
<>
|
||||
<FaSpinner className={styles.spinner} /> Wird verarbeitet...
|
||||
</>
|
||||
) : (
|
||||
'Einladung annehmen'
|
||||
)}
|
||||
</button>
|
||||
<Link to="/" className={styles.secondaryButton}>
|
||||
Abbrechen
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Not authenticated - show registration form or login option
|
||||
return (
|
||||
<div className={styles.container}>
|
||||
<div className={styles.card}>
|
||||
<div className={styles.header}>
|
||||
<h1>Einladung annehmen</h1>
|
||||
<p>Erstellen Sie ein Konto, um die Einladung anzunehmen.</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className={styles.errorMessage}>
|
||||
<FaTimesCircle /> {error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleRegister} className={styles.form}>
|
||||
<div className={styles.formRow}>
|
||||
<div className={styles.formGroup}>
|
||||
<label htmlFor="firstname">Vorname</label>
|
||||
<input
|
||||
type="text"
|
||||
id="firstname"
|
||||
name="firstname"
|
||||
value={formData.firstname}
|
||||
onChange={handleChange}
|
||||
placeholder="Max"
|
||||
/>
|
||||
</div>
|
||||
<div className={styles.formGroup}>
|
||||
<label htmlFor="lastname">Nachname</label>
|
||||
<input
|
||||
type="text"
|
||||
id="lastname"
|
||||
name="lastname"
|
||||
value={formData.lastname}
|
||||
onChange={handleChange}
|
||||
placeholder="Mustermann"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<label htmlFor="username">
|
||||
<FaUser /> Benutzername *
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="username"
|
||||
name="username"
|
||||
value={formData.username}
|
||||
onChange={handleChange}
|
||||
placeholder="maxmustermann"
|
||||
required
|
||||
minLength={3}
|
||||
maxLength={50}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<label htmlFor="email">
|
||||
<FaEnvelope /> E-Mail *
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
id="email"
|
||||
name="email"
|
||||
value={formData.email}
|
||||
onChange={handleChange}
|
||||
placeholder="max@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<label htmlFor="password">
|
||||
<FaLock /> Passwort * (min. 8 Zeichen)
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="password"
|
||||
name="password"
|
||||
value={formData.password}
|
||||
onChange={handleChange}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
minLength={8}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.formGroup}>
|
||||
<label htmlFor="confirmPassword">
|
||||
<FaLock /> Passwort bestätigen *
|
||||
</label>
|
||||
<input
|
||||
type="password"
|
||||
id="confirmPassword"
|
||||
value={confirmPassword}
|
||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
||||
placeholder="••••••••"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={styles.actions}>
|
||||
<button
|
||||
type="submit"
|
||||
className={styles.primaryButton}
|
||||
disabled={accepting}
|
||||
>
|
||||
{accepting ? (
|
||||
<>
|
||||
<FaSpinner className={styles.spinner} /> Wird verarbeitet...
|
||||
</>
|
||||
) : (
|
||||
'Konto erstellen & Einladung annehmen'
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<div className={styles.divider}>
|
||||
<span>oder</span>
|
||||
</div>
|
||||
|
||||
<div className={styles.loginOption}>
|
||||
<p>Sie haben bereits ein Konto?</p>
|
||||
<Link to={`/login?redirect=/invite/${token}`} className={styles.secondaryButton}>
|
||||
Anmelden und Einladung annehmen
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default InvitePage;
|
||||
|
|
@ -265,3 +265,175 @@
|
|||
:global(.dark-theme) .infoValue {
|
||||
color: var(--text-primary-dark, #ffffff);
|
||||
}
|
||||
|
||||
/* Error Text */
|
||||
.errorText {
|
||||
color: var(--error-color, #dc2626);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Saving Indicator */
|
||||
.savingIndicator {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-secondary, #666);
|
||||
margin-left: 0.5rem;
|
||||
}
|
||||
|
||||
/* Error Message */
|
||||
.errorMessage {
|
||||
background: var(--error-bg, #fef2f2);
|
||||
border: 1px solid var(--error-border, #fecaca);
|
||||
color: var(--error-color, #dc2626);
|
||||
padding: 0.75rem 1rem;
|
||||
border-radius: 6px;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
/* User Info Card */
|
||||
.userInfoCard {
|
||||
background: var(--surface-color, #f9fafb);
|
||||
border: 1px solid var(--border-color, #e5e7eb);
|
||||
border-radius: 8px;
|
||||
padding: 1rem;
|
||||
margin-top: 1rem;
|
||||
}
|
||||
|
||||
.userInfoRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0;
|
||||
border-bottom: 1px solid var(--border-color, #e5e7eb);
|
||||
}
|
||||
|
||||
.userInfoRow:last-child {
|
||||
border-bottom: none;
|
||||
padding-bottom: 0;
|
||||
}
|
||||
|
||||
.userInfoRow:first-child {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
.userInfoLabel {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.userInfoValue {
|
||||
font-size: 0.8125rem;
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
}
|
||||
|
||||
/* Modal Overlay */
|
||||
.modalOverlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.5);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 1000;
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
/* Modal Content */
|
||||
.modalContent {
|
||||
background: var(--bg-primary, #ffffff);
|
||||
border-radius: 12px;
|
||||
width: 100%;
|
||||
max-width: 500px;
|
||||
max-height: 90vh;
|
||||
overflow: auto;
|
||||
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1), 0 10px 10px -5px rgba(0, 0, 0, 0.04);
|
||||
}
|
||||
|
||||
.modalHeader {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: 1.25rem 1.5rem;
|
||||
border-bottom: 1px solid var(--border-color, #e5e7eb);
|
||||
}
|
||||
|
||||
.modalHeader h2 {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
}
|
||||
|
||||
.closeButton {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-secondary, #6b7280);
|
||||
cursor: pointer;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
transition: color 0.2s;
|
||||
}
|
||||
|
||||
.closeButton:hover {
|
||||
color: var(--text-primary, #1a1a1a);
|
||||
}
|
||||
|
||||
.modalBody {
|
||||
padding: 1.5rem;
|
||||
}
|
||||
|
||||
/* Dark Theme - Additional styles */
|
||||
:global(.dark-theme) .errorText {
|
||||
color: var(--error-color-dark, #f87171);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .savingIndicator {
|
||||
color: var(--text-secondary-dark, #9ca3af);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .errorMessage {
|
||||
background: var(--error-bg-dark, #450a0a);
|
||||
border-color: var(--error-border-dark, #991b1b);
|
||||
color: var(--error-color-dark, #f87171);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .userInfoCard {
|
||||
background: var(--surface-dark, #1f2937);
|
||||
border-color: var(--border-dark, #374151);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .userInfoRow {
|
||||
border-bottom-color: var(--border-dark, #374151);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .userInfoLabel {
|
||||
color: var(--text-secondary-dark, #9ca3af);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .userInfoValue {
|
||||
color: var(--text-primary-dark, #ffffff);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .modalContent {
|
||||
background: var(--bg-dark, #111827);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .modalHeader {
|
||||
border-bottom-color: var(--border-dark, #374151);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .modalHeader h2 {
|
||||
color: var(--text-primary-dark, #ffffff);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .closeButton {
|
||||
color: var(--text-secondary-dark, #9ca3af);
|
||||
}
|
||||
|
||||
:global(.dark-theme) .closeButton:hover {
|
||||
color: var(--text-primary-dark, #ffffff);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,20 +4,105 @@
|
|||
* Benutzer-Einstellungen (System-Level, ohne Instanz-Kontext).
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useCallback } from 'react';
|
||||
import { useLanguage } from '../providers/language/LanguageContext';
|
||||
import { useCurrentUser, useUser } from '../hooks/useUsers';
|
||||
import { setUserDataCache, getUserDataCache } from '../utils/userCache';
|
||||
import { FormGeneratorForm } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm';
|
||||
import type { AttributeDefinition } from '../components/FormGenerator/FormGeneratorForm/FormGeneratorForm';
|
||||
import styles from './Settings.module.css';
|
||||
|
||||
// =============================================================================
|
||||
// PROFILE EDIT MODAL
|
||||
// =============================================================================
|
||||
|
||||
interface ProfileEditModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
userData: any;
|
||||
onSave: (data: any) => Promise<void>;
|
||||
}
|
||||
|
||||
const ProfileEditModal: React.FC<ProfileEditModalProps> = ({ isOpen, onClose, userData, onSave }) => {
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Define editable profile fields
|
||||
const profileAttributes: AttributeDefinition[] = [
|
||||
{
|
||||
name: 'fullName',
|
||||
type: 'string',
|
||||
label: 'Vollständiger Name',
|
||||
description: 'Ihr vollständiger Name',
|
||||
required: false,
|
||||
placeholder: 'Max Mustermann'
|
||||
},
|
||||
{
|
||||
name: 'email',
|
||||
type: 'email',
|
||||
label: 'E-Mail-Adresse',
|
||||
description: 'Ihre E-Mail-Adresse für Benachrichtigungen',
|
||||
required: true,
|
||||
placeholder: 'name@example.com'
|
||||
}
|
||||
];
|
||||
|
||||
const handleSubmit = async (formData: any) => {
|
||||
setIsSaving(true);
|
||||
setError(null);
|
||||
try {
|
||||
await onSave(formData);
|
||||
onClose();
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Fehler beim Speichern des Profils');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className={styles.modalOverlay} onClick={onClose}>
|
||||
<div className={styles.modalContent} onClick={(e) => e.stopPropagation()}>
|
||||
<div className={styles.modalHeader}>
|
||||
<h2>Profil bearbeiten</h2>
|
||||
<button className={styles.closeButton} onClick={onClose}>×</button>
|
||||
</div>
|
||||
<div className={styles.modalBody}>
|
||||
{error && <div className={styles.errorMessage}>{error}</div>}
|
||||
<FormGeneratorForm
|
||||
attributes={profileAttributes}
|
||||
data={userData}
|
||||
mode="edit"
|
||||
onSubmit={handleSubmit}
|
||||
onCancel={onClose}
|
||||
submitButtonText={isSaving ? 'Speichern...' : 'Speichern'}
|
||||
cancelButtonText="Abbrechen"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
// =============================================================================
|
||||
// SETTINGS PAGE
|
||||
// =============================================================================
|
||||
|
||||
export const SettingsPage: React.FC = () => {
|
||||
const { currentLanguage, setLanguage } = useLanguage();
|
||||
const { user: currentUser, refetch: refetchUser } = useCurrentUser();
|
||||
const { updateUser } = useUser();
|
||||
|
||||
const [theme, setTheme] = useState<'light' | 'dark'>(
|
||||
() => (localStorage.getItem('theme') as 'light' | 'dark') || 'light'
|
||||
);
|
||||
const [isProfileModalOpen, setIsProfileModalOpen] = useState(false);
|
||||
const [isSavingLanguage, setIsSavingLanguage] = useState(false);
|
||||
const [languageError, setLanguageError] = useState<string | null>(null);
|
||||
|
||||
// Handle theme change
|
||||
const handleThemeChange = (newTheme: 'light' | 'dark') => {
|
||||
setTheme(newTheme);
|
||||
localStorage.setItem('theme', newTheme);
|
||||
|
|
@ -32,6 +117,87 @@ export const SettingsPage: React.FC = () => {
|
|||
document.documentElement.setAttribute('data-theme', newTheme);
|
||||
};
|
||||
|
||||
// Handle language change - save to backend and update cache
|
||||
const handleLanguageChange = useCallback(async (newLanguage: 'de' | 'en' | 'fr') => {
|
||||
if (!currentUser?.id || !currentUser?.username) return;
|
||||
|
||||
setIsSavingLanguage(true);
|
||||
setLanguageError(null);
|
||||
|
||||
try {
|
||||
// 1. Build full user object for update (backend requires full User model)
|
||||
const userUpdateData = {
|
||||
id: currentUser.id,
|
||||
username: currentUser.username,
|
||||
email: currentUser.email,
|
||||
fullName: currentUser.fullName,
|
||||
language: newLanguage,
|
||||
enabled: currentUser.enabled ?? true,
|
||||
authenticationAuthority: currentUser.authenticationAuthority || 'local'
|
||||
};
|
||||
|
||||
// 2. Save to backend
|
||||
await updateUser(currentUser.id, userUpdateData);
|
||||
|
||||
// 3. Update sessionStorage cache
|
||||
const cachedUser = getUserDataCache();
|
||||
if (cachedUser) {
|
||||
setUserDataCache({ ...cachedUser, language: newLanguage });
|
||||
}
|
||||
|
||||
// 4. Update UI language context
|
||||
setLanguage(newLanguage);
|
||||
|
||||
// 5. Dispatch event to notify other components
|
||||
window.dispatchEvent(new CustomEvent('userInfoUpdated'));
|
||||
|
||||
console.log('Language updated successfully to:', newLanguage);
|
||||
} catch (err: any) {
|
||||
console.error('Failed to update language:', err);
|
||||
setLanguageError('Sprache konnte nicht gespeichert werden');
|
||||
} finally {
|
||||
setIsSavingLanguage(false);
|
||||
}
|
||||
}, [currentUser, updateUser, setLanguage]);
|
||||
|
||||
// Handle profile save
|
||||
const handleProfileSave = useCallback(async (formData: any) => {
|
||||
if (!currentUser?.id || !currentUser?.username) throw new Error('Nicht angemeldet');
|
||||
|
||||
// Build full user object for update (backend requires full User model)
|
||||
const userUpdateData = {
|
||||
id: currentUser.id,
|
||||
username: currentUser.username,
|
||||
email: formData.email || currentUser.email,
|
||||
fullName: formData.fullName || currentUser.fullName,
|
||||
language: currentUser.language || 'de',
|
||||
enabled: currentUser.enabled ?? true,
|
||||
authenticationAuthority: currentUser.authenticationAuthority || 'local'
|
||||
};
|
||||
|
||||
// Update user via API
|
||||
const updatedUser = await updateUser(currentUser.id, userUpdateData);
|
||||
|
||||
// Update sessionStorage cache
|
||||
const cachedUser = getUserDataCache();
|
||||
if (cachedUser) {
|
||||
setUserDataCache({
|
||||
...cachedUser,
|
||||
fullName: updatedUser.fullName || cachedUser.fullName,
|
||||
email: updatedUser.email || cachedUser.email
|
||||
});
|
||||
}
|
||||
|
||||
// Refetch user data
|
||||
if (refetchUser) {
|
||||
await refetchUser();
|
||||
}
|
||||
|
||||
// Dispatch event to notify other components (e.g., sidebar)
|
||||
window.dispatchEvent(new CustomEvent('userInfoUpdated'));
|
||||
|
||||
}, [currentUser, updateUser, refetchUser]);
|
||||
|
||||
return (
|
||||
<div className={styles.settings}>
|
||||
<header className={styles.header}>
|
||||
|
|
@ -57,13 +223,13 @@ export const SettingsPage: React.FC = () => {
|
|||
className={`${styles.themeButton} ${theme === 'light' ? styles.active : ''}`}
|
||||
onClick={() => handleThemeChange('light')}
|
||||
>
|
||||
☀️ Hell
|
||||
Hell
|
||||
</button>
|
||||
<button
|
||||
className={`${styles.themeButton} ${theme === 'dark' ? styles.active : ''}`}
|
||||
onClick={() => handleThemeChange('dark')}
|
||||
>
|
||||
🌙 Dunkel
|
||||
Dunkel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -74,18 +240,21 @@ export const SettingsPage: React.FC = () => {
|
|||
<label className={styles.settingLabel}>Sprache</label>
|
||||
<p className={styles.settingDescription}>
|
||||
Wähle die Anzeigesprache der Anwendung.
|
||||
{languageError && <span className={styles.errorText}> {languageError}</span>}
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.settingControl}>
|
||||
<select
|
||||
className={styles.select}
|
||||
value={currentLanguage}
|
||||
onChange={(e) => setLanguage(e.target.value as 'de' | 'en' | 'fr')}
|
||||
onChange={(e) => handleLanguageChange(e.target.value as 'de' | 'en' | 'fr')}
|
||||
disabled={isSavingLanguage}
|
||||
>
|
||||
<option value="de">Deutsch</option>
|
||||
<option value="en">English</option>
|
||||
<option value="fr">Français</option>
|
||||
</select>
|
||||
{isSavingLanguage && <span className={styles.savingIndicator}>Speichern...</span>}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
|
@ -98,29 +267,36 @@ export const SettingsPage: React.FC = () => {
|
|||
<div className={styles.settingInfo}>
|
||||
<label className={styles.settingLabel}>Profil bearbeiten</label>
|
||||
<p className={styles.settingDescription}>
|
||||
Ändere deinen Namen, E-Mail-Adresse und Profilbild.
|
||||
Ändere deinen Namen und E-Mail-Adresse.
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.settingControl}>
|
||||
<button className={styles.button}>
|
||||
<button
|
||||
className={styles.button}
|
||||
onClick={() => setIsProfileModalOpen(true)}
|
||||
>
|
||||
Profil öffnen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={styles.settingRow}>
|
||||
<div className={styles.settingInfo}>
|
||||
<label className={styles.settingLabel}>Passwort ändern</label>
|
||||
<p className={styles.settingDescription}>
|
||||
Aktualisiere dein Passwort für mehr Sicherheit.
|
||||
</p>
|
||||
{/* Current user info display */}
|
||||
{currentUser && (
|
||||
<div className={styles.userInfoCard}>
|
||||
<div className={styles.userInfoRow}>
|
||||
<span className={styles.userInfoLabel}>Benutzername</span>
|
||||
<span className={styles.userInfoValue}>{currentUser.username}</span>
|
||||
</div>
|
||||
<div className={styles.userInfoRow}>
|
||||
<span className={styles.userInfoLabel}>Name</span>
|
||||
<span className={styles.userInfoValue}>{currentUser.fullName || '-'}</span>
|
||||
</div>
|
||||
<div className={styles.userInfoRow}>
|
||||
<span className={styles.userInfoLabel}>E-Mail</span>
|
||||
<span className={styles.userInfoValue}>{currentUser.email || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className={styles.settingControl}>
|
||||
<button className={styles.button}>
|
||||
Passwort ändern
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Info */}
|
||||
|
|
@ -134,11 +310,19 @@ export const SettingsPage: React.FC = () => {
|
|||
</div>
|
||||
<div className={styles.infoRow}>
|
||||
<span className={styles.infoLabel}>Build</span>
|
||||
<span className={styles.infoValue}>2026.01.16</span>
|
||||
<span className={styles.infoValue}>2026.01.20</span>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</main>
|
||||
|
||||
{/* Profile Edit Modal */}
|
||||
<ProfileEditModal
|
||||
isOpen={isProfileModalOpen}
|
||||
onClose={() => setIsProfileModalOpen(false)}
|
||||
userData={currentUser}
|
||||
onSave={handleProfileSave}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -86,6 +86,61 @@
|
|||
border-color: var(--text-secondary);
|
||||
}
|
||||
|
||||
/* Filter Section Styles */
|
||||
.filterSection {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
gap: 1rem;
|
||||
margin-bottom: 1rem;
|
||||
flex-wrap: wrap;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.filterGroup {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
|
||||
.filterLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: var(--text-secondary);
|
||||
}
|
||||
|
||||
.filterSelect {
|
||||
padding: 0.5rem 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-primary);
|
||||
color: var(--text-primary);
|
||||
min-width: 200px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.filterSelect:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color, #f25843);
|
||||
}
|
||||
|
||||
/* Info Box */
|
||||
.infoBox {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
margin-bottom: 1rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.tableContainer {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
|
|
@ -276,6 +331,75 @@
|
|||
color: var(--danger-color, #e53e3e);
|
||||
}
|
||||
|
||||
/* Badge */
|
||||
.badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
/* Checkbox Label */
|
||||
.checkboxLabel {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.checkboxLabel input[type="checkbox"] {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* URL Box */
|
||||
.urlBox {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.urlInput {
|
||||
flex: 1;
|
||||
padding: 0.75rem;
|
||||
font-size: 0.875rem;
|
||||
font-family: monospace;
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: 6px;
|
||||
background: var(--bg-secondary);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.urlInput:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color, #f25843);
|
||||
}
|
||||
|
||||
.copyButton {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
padding: 0.75rem 1rem;
|
||||
background: var(--primary-color, #f25843);
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: background 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.copyButton:hover {
|
||||
background: var(--primary-dark, #d94d3a);
|
||||
}
|
||||
|
||||
/* Dark theme adjustments */
|
||||
:global(.dark-theme) .modal {
|
||||
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);
|
||||
|
|
|
|||
347
src/pages/admin/AdminFeatureAccessPage.tsx
Normal file
347
src/pages/admin/AdminFeatureAccessPage.tsx
Normal file
|
|
@ -0,0 +1,347 @@
|
|||
/**
|
||||
* AdminFeatureAccessPage
|
||||
*
|
||||
* Admin page for managing feature instances within mandates.
|
||||
* Allows creating, viewing, and managing feature instances.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useFeatureAccess, type Feature, type FeatureInstance } from '../../hooks/useFeatureAccess';
|
||||
import { useUserMandates, type Mandate } from '../../hooks/useUserMandates';
|
||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||
import { FaPlus, FaSync, FaCube, FaBuilding, FaCogs } from 'react-icons/fa';
|
||||
import api from '../../api';
|
||||
import styles from './Admin.module.css';
|
||||
|
||||
export const AdminFeatureAccessPage: React.FC = () => {
|
||||
const {
|
||||
features,
|
||||
instances,
|
||||
loading,
|
||||
error,
|
||||
fetchFeatures,
|
||||
fetchInstances,
|
||||
createInstance,
|
||||
deleteInstance,
|
||||
syncInstanceRoles,
|
||||
} = useFeatureAccess();
|
||||
|
||||
const { fetchMandates } = useUserMandates();
|
||||
|
||||
// State
|
||||
const [mandates, setMandates] = useState<Mandate[]>([]);
|
||||
const [selectedMandateId, setSelectedMandateId] = useState<string>('');
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [syncingInstance, setSyncingInstance] = useState<string | null>(null);
|
||||
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||
|
||||
// Load features, mandates, and attributes on mount
|
||||
useEffect(() => {
|
||||
fetchFeatures();
|
||||
fetchMandates().then(setMandates);
|
||||
// Fetch FeatureInstance attributes from backend
|
||||
api.get('/api/attributes/FeatureInstance').then(response => {
|
||||
const attrs = response.data?.attributes || response.data || [];
|
||||
setBackendAttributes(Array.isArray(attrs) ? attrs : []);
|
||||
}).catch(() => setBackendAttributes([]));
|
||||
}, [fetchFeatures, fetchMandates]);
|
||||
|
||||
// Load instances when mandate changes
|
||||
useEffect(() => {
|
||||
if (selectedMandateId) {
|
||||
fetchInstances(selectedMandateId);
|
||||
}
|
||||
}, [selectedMandateId, fetchInstances]);
|
||||
|
||||
// Table columns
|
||||
const columns = useMemo(() => [
|
||||
{ key: 'label', label: 'Name', type: 'string' as const, sortable: true, filterable: true, searchable: true, width: 200 },
|
||||
{ key: 'featureCode', label: 'Feature', type: 'string' as const, sortable: true, filterable: true, width: 150,
|
||||
render: (value: string) => {
|
||||
const feature = features.find(f => f.code === value);
|
||||
if (feature) {
|
||||
const label = typeof feature.label === 'object'
|
||||
? (feature.label.de || feature.label.en || value)
|
||||
: feature.label;
|
||||
return label;
|
||||
}
|
||||
return value;
|
||||
}
|
||||
},
|
||||
{ key: 'enabled', label: 'Aktiv', type: 'boolean' as const, sortable: true, filterable: true, width: 80 },
|
||||
], [features]);
|
||||
|
||||
// Form attributes from backend - merge with dynamic feature options
|
||||
const createFields: AttributeDefinition[] = useMemo(() => {
|
||||
const excludedFields = ['id', 'mandateId', 'enabled'];
|
||||
const featureOptions = features.map(f => ({
|
||||
value: f.code,
|
||||
label: typeof f.label === 'object'
|
||||
? (f.label.de || f.label.en || f.code)
|
||||
: (f.label || f.code)
|
||||
}));
|
||||
|
||||
return backendAttributes
|
||||
.filter(attr => !excludedFields.includes(attr.name))
|
||||
.map(attr => ({
|
||||
...attr,
|
||||
// Override featureCode: make editable for create and add dynamic options
|
||||
readonly: attr.name === 'featureCode' ? false : attr.readonly,
|
||||
editable: attr.name === 'featureCode' ? true : attr.editable,
|
||||
options: attr.name === 'featureCode' ? featureOptions : attr.options,
|
||||
})) as AttributeDefinition[];
|
||||
}, [features, backendAttributes]);
|
||||
|
||||
// Handle create instance
|
||||
const handleCreateInstance = async (data: { featureCode: string; label: string; copyTemplateRoles?: boolean }) => {
|
||||
if (!selectedMandateId) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await createInstance(selectedMandateId, {
|
||||
featureCode: data.featureCode,
|
||||
label: data.label,
|
||||
copyTemplateRoles: data.copyTemplateRoles !== false
|
||||
});
|
||||
if (result.success) {
|
||||
setShowCreateModal(false);
|
||||
fetchInstances(selectedMandateId);
|
||||
} else {
|
||||
alert(result.error || 'Fehler beim Erstellen der Feature-Instanz');
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle delete instance
|
||||
const handleDeleteInstance = async (instance: FeatureInstance) => {
|
||||
if (!selectedMandateId) return;
|
||||
if (window.confirm(`Möchten Sie die Feature-Instanz "${instance.label}" wirklich löschen? Alle zugehörigen Daten werden gelöscht.`)) {
|
||||
const result = await deleteInstance(selectedMandateId, instance.id);
|
||||
if (!result.success) {
|
||||
alert(result.error || 'Fehler beim Löschen der Feature-Instanz');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle sync roles
|
||||
const handleSyncRoles = async (instance: FeatureInstance) => {
|
||||
if (!selectedMandateId) return;
|
||||
setSyncingInstance(instance.id);
|
||||
try {
|
||||
const result = await syncInstanceRoles(selectedMandateId, instance.id, true);
|
||||
if (result.success && result.data) {
|
||||
alert(`Rollen synchronisiert:\n- Hinzugefügt: ${result.data.added}\n- Entfernt: ${result.data.removed}\n- Unverändert: ${result.data.unchanged}`);
|
||||
} else {
|
||||
alert(result.error || 'Fehler beim Synchronisieren der Rollen');
|
||||
}
|
||||
} finally {
|
||||
setSyncingInstance(null);
|
||||
}
|
||||
};
|
||||
|
||||
// Get mandate name
|
||||
const getMandateName = (mandate: Mandate) => {
|
||||
if (typeof mandate.name === 'object') {
|
||||
return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id;
|
||||
}
|
||||
return mandate.name || mandate.id;
|
||||
};
|
||||
|
||||
// Get feature label
|
||||
const getFeatureLabel = (code: string) => {
|
||||
const feature = features.find(f => f.code === code);
|
||||
if (feature) {
|
||||
return typeof feature.label === 'object'
|
||||
? (feature.label.de || feature.label.en || code)
|
||||
: (feature.label || code);
|
||||
}
|
||||
return code;
|
||||
};
|
||||
|
||||
if (error && !selectedMandateId) {
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={styles.errorContainer}>
|
||||
<span className={styles.errorIcon}>⚠️</span>
|
||||
<p className={styles.errorMessage}>Fehler: {error}</p>
|
||||
<button className={styles.secondaryButton} onClick={() => fetchFeatures()}>
|
||||
<FaSync /> Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h1 className={styles.pageTitle}>Feature-Instanzen</h1>
|
||||
<p className={styles.pageSubtitle}>Verwalten Sie Feature-Instanzen für jeden Mandanten</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mandate Selector */}
|
||||
<div className={styles.filterSection}>
|
||||
<div className={styles.filterGroup}>
|
||||
<label className={styles.filterLabel}>
|
||||
<FaBuilding style={{ marginRight: 8 }} />
|
||||
Mandant auswählen:
|
||||
</label>
|
||||
<select
|
||||
className={styles.filterSelect}
|
||||
value={selectedMandateId}
|
||||
onChange={(e) => setSelectedMandateId(e.target.value)}
|
||||
>
|
||||
<option value="">-- Mandant wählen --</option>
|
||||
{mandates.map(m => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{getMandateName(m)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedMandateId && (
|
||||
<div className={styles.headerActions}>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
onClick={() => fetchInstances(selectedMandateId)}
|
||||
disabled={loading}
|
||||
>
|
||||
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
|
||||
</button>
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
disabled={features.length === 0}
|
||||
>
|
||||
<FaPlus /> Neue Instanz
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Available Features Info */}
|
||||
{features.length > 0 && (
|
||||
<div className={styles.infoBox}>
|
||||
<FaCube style={{ marginRight: 8 }} />
|
||||
<span>Verfügbare Features: </span>
|
||||
{features.map((f, i) => (
|
||||
<span key={f.code}>
|
||||
{i > 0 && ', '}
|
||||
<strong>{getFeatureLabel(f.code)}</strong>
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{!selectedMandateId ? (
|
||||
<div className={styles.emptyState}>
|
||||
<FaBuilding className={styles.emptyIcon} />
|
||||
<h3 className={styles.emptyTitle}>Kein Mandant ausgewählt</h3>
|
||||
<p className={styles.emptyDescription}>
|
||||
Wählen Sie einen Mandanten aus, um dessen Feature-Instanzen zu verwalten.
|
||||
</p>
|
||||
</div>
|
||||
) : loading && instances.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Lade Feature-Instanzen...</span>
|
||||
</div>
|
||||
) : instances.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<FaCube className={styles.emptyIcon} />
|
||||
<h3 className={styles.emptyTitle}>Keine Feature-Instanzen</h3>
|
||||
<p className={styles.emptyDescription}>
|
||||
Für diesen Mandanten wurden noch keine Feature-Instanzen erstellt.
|
||||
</p>
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
disabled={features.length === 0}
|
||||
>
|
||||
<FaPlus /> Erste Instanz erstellen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.tableContainer}>
|
||||
<FormGeneratorTable
|
||||
data={instances}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
pagination={true}
|
||||
pageSize={25}
|
||||
searchable={true}
|
||||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={false}
|
||||
actionButtons={[
|
||||
{
|
||||
type: 'delete' as const,
|
||||
title: 'Instanz löschen',
|
||||
}
|
||||
]}
|
||||
customActions={[
|
||||
{
|
||||
id: 'syncRoles',
|
||||
icon: <FaCogs />,
|
||||
onClick: handleSyncRoles,
|
||||
title: 'Rollen synchronisieren',
|
||||
loading: (row: FeatureInstance) => syncingInstance === row.id,
|
||||
}
|
||||
]}
|
||||
onDelete={handleDeleteInstance}
|
||||
hookData={{
|
||||
refetch: () => fetchInstances(selectedMandateId),
|
||||
handleDelete: handleDeleteInstance,
|
||||
}}
|
||||
emptyMessage="Keine Feature-Instanzen gefunden"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Instance Modal */}
|
||||
{showCreateModal && (
|
||||
<div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
|
||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||||
<div className={styles.modalHeader}>
|
||||
<h2 className={styles.modalTitle}>Neue Feature-Instanz erstellen</h2>
|
||||
<button
|
||||
className={styles.modalClose}
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.modalContent}>
|
||||
{features.length === 0 ? (
|
||||
<p>Keine Features verfügbar. Bitte wenden Sie sich an den System-Administrator.</p>
|
||||
) : createFields.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Lade Formular...</span>
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorForm
|
||||
attributes={createFields}
|
||||
mode="create"
|
||||
onSubmit={handleCreateInstance}
|
||||
onCancel={() => setShowCreateModal(false)}
|
||||
submitButtonText="Erstellen"
|
||||
cancelButtonText="Abbrechen"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminFeatureAccessPage;
|
||||
457
src/pages/admin/AdminInvitationsPage.tsx
Normal file
457
src/pages/admin/AdminInvitationsPage.tsx
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
/**
|
||||
* AdminInvitationsPage
|
||||
*
|
||||
* Admin page for managing invitations within a mandate.
|
||||
* Allows creating, viewing, and revoking invitations.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useInvitations, type Invitation, type InvitationCreate } from '../../hooks/useInvitations';
|
||||
import { useUserMandates, type Mandate, type Role } from '../../hooks/useUserMandates';
|
||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||
import { FaPlus, FaSync, FaEnvelopeOpenText, FaBuilding, FaCopy, FaLink } from 'react-icons/fa';
|
||||
import api from '../../api';
|
||||
import styles from './Admin.module.css';
|
||||
|
||||
export const AdminInvitationsPage: React.FC = () => {
|
||||
const {
|
||||
invitations,
|
||||
loading,
|
||||
error,
|
||||
fetchInvitations,
|
||||
createInvitation,
|
||||
revokeInvitation,
|
||||
} = useInvitations();
|
||||
|
||||
const { fetchMandates, fetchRoles } = useUserMandates();
|
||||
|
||||
// State
|
||||
const [mandates, setMandates] = useState<Mandate[]>([]);
|
||||
const [selectedMandateId, setSelectedMandateId] = useState<string>('');
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showUrlModal, setShowUrlModal] = useState<Invitation | null>(null);
|
||||
const [showExpired, setShowExpired] = useState(false);
|
||||
const [showUsed, setShowUsed] = useState(false);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [copySuccess, setCopySuccess] = useState(false);
|
||||
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||
|
||||
// Load mandates and attributes on mount
|
||||
useEffect(() => {
|
||||
const loadMandates = async () => {
|
||||
const data = await fetchMandates();
|
||||
setMandates(data);
|
||||
if (data.length > 0 && !selectedMandateId) {
|
||||
setSelectedMandateId(data[0].id);
|
||||
}
|
||||
};
|
||||
loadMandates();
|
||||
// Fetch Invitation attributes from backend
|
||||
api.get('/api/attributes/Invitation').then(response => {
|
||||
const attrs = response.data?.attributes || response.data || [];
|
||||
setBackendAttributes(Array.isArray(attrs) ? attrs : []);
|
||||
}).catch(() => setBackendAttributes([]));
|
||||
}, [fetchMandates]);
|
||||
|
||||
// Load invitations and roles when mandate changes
|
||||
useEffect(() => {
|
||||
if (selectedMandateId) {
|
||||
fetchInvitations(selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed });
|
||||
fetchRoles(selectedMandateId).then(setRoles);
|
||||
}
|
||||
}, [selectedMandateId, showExpired, showUsed, fetchInvitations, fetchRoles]);
|
||||
|
||||
// Format timestamp
|
||||
const formatDate = (timestamp: number) => {
|
||||
if (!timestamp) return '-';
|
||||
const date = new Date(timestamp * 1000);
|
||||
return date.toLocaleString('de-CH', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
year: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
// Table columns
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
key: 'email',
|
||||
label: 'E-Mail',
|
||||
type: 'string' as const,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
width: 200,
|
||||
render: (value: string) => value || '(beliebig)'
|
||||
},
|
||||
{
|
||||
key: 'roleIds',
|
||||
label: 'Rollen',
|
||||
type: 'array' as const,
|
||||
sortable: false,
|
||||
filterable: false,
|
||||
width: 150,
|
||||
render: (value: string[]) => {
|
||||
if (!value || value.length === 0) return '-';
|
||||
return value.map(roleId => {
|
||||
const role = roles.find(r => r.id === roleId);
|
||||
return role?.roleLabel || roleId;
|
||||
}).join(', ');
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'expiresAt',
|
||||
label: 'Gültig bis',
|
||||
type: 'number' as const,
|
||||
sortable: true,
|
||||
width: 150,
|
||||
render: (value: number, row: Invitation) => {
|
||||
const text = formatDate(value);
|
||||
const isExpired = value < Date.now() / 1000;
|
||||
return (
|
||||
<span style={{ color: isExpired ? 'var(--danger-color)' : 'inherit' }}>
|
||||
{text} {isExpired && '(abgelaufen)'}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'currentUses',
|
||||
label: 'Verwendet',
|
||||
type: 'string' as const,
|
||||
sortable: true,
|
||||
width: 100,
|
||||
render: (value: number, row: Invitation) => `${value || 0} / ${row.maxUses || 1}`
|
||||
},
|
||||
{
|
||||
key: 'createdAt',
|
||||
label: 'Erstellt',
|
||||
type: 'number' as const,
|
||||
sortable: true,
|
||||
width: 150,
|
||||
render: (value: number) => formatDate(value)
|
||||
},
|
||||
], [roles]);
|
||||
|
||||
// Form attributes from backend - merge with dynamic role options
|
||||
const createFields: AttributeDefinition[] = useMemo(() => {
|
||||
const excludedFields = ['id', 'mandateId', 'token', 'createdBy', 'createdAt', 'expiresAt', 'currentUses', 'inviteUrl'];
|
||||
const roleOptions = roles
|
||||
.filter(r => !r.featureInstanceId) // Only mandate-level roles
|
||||
.map(r => ({ value: r.id, label: r.roleLabel }));
|
||||
|
||||
const fields = backendAttributes
|
||||
.filter(attr => !excludedFields.includes(attr.name))
|
||||
.map(attr => ({
|
||||
...attr,
|
||||
// Override roleIds options with dynamic data
|
||||
options: attr.name === 'roleIds' ? roleOptions : attr.options,
|
||||
})) as AttributeDefinition[];
|
||||
|
||||
// Add helper field expiresInHours if not in model but fields exist
|
||||
if (fields.length > 0 && !fields.find(f => f.name === 'expiresInHours')) {
|
||||
fields.push({ name: 'expiresInHours', label: 'Gültigkeitsdauer (Stunden)', type: 'number' as any,
|
||||
required: true, default: 72, min: 1, max: 720 });
|
||||
}
|
||||
return fields;
|
||||
}, [roles, backendAttributes]);
|
||||
|
||||
// Handle create invitation
|
||||
const handleCreateInvitation = async (data: InvitationCreate) => {
|
||||
if (!selectedMandateId) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await createInvitation(selectedMandateId, data);
|
||||
if (result.success && result.data) {
|
||||
setShowCreateModal(false);
|
||||
setShowUrlModal(result.data);
|
||||
fetchInvitations(selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed });
|
||||
} else {
|
||||
alert(result.error || 'Fehler beim Erstellen der Einladung');
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle revoke invitation
|
||||
const handleRevokeInvitation = async (invitation: Invitation) => {
|
||||
if (!selectedMandateId) return;
|
||||
if (window.confirm('Möchten Sie diese Einladung wirklich widerrufen?')) {
|
||||
const result = await revokeInvitation(selectedMandateId, invitation.id);
|
||||
if (!result.success) {
|
||||
alert(result.error || 'Fehler beim Widerrufen der Einladung');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle show URL
|
||||
const handleShowUrl = (invitation: Invitation) => {
|
||||
setShowUrlModal(invitation);
|
||||
};
|
||||
|
||||
// Copy URL to clipboard
|
||||
const handleCopyUrl = async (url: string) => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(url);
|
||||
setCopySuccess(true);
|
||||
setTimeout(() => setCopySuccess(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Get mandate name
|
||||
const getMandateName = (mandate: Mandate) => {
|
||||
if (typeof mandate.name === 'object') {
|
||||
return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id;
|
||||
}
|
||||
return mandate.name || mandate.id;
|
||||
};
|
||||
|
||||
if (error && !selectedMandateId) {
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={styles.errorContainer}>
|
||||
<span className={styles.errorIcon}>⚠️</span>
|
||||
<p className={styles.errorMessage}>Fehler: {error}</p>
|
||||
<button className={styles.secondaryButton} onClick={() => fetchMandates()}>
|
||||
<FaSync /> Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h1 className={styles.pageTitle}>Einladungen</h1>
|
||||
<p className={styles.pageSubtitle}>Erstellen und verwalten Sie Einladungen für neue Benutzer</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mandate Selector and Filters */}
|
||||
<div className={styles.filterSection}>
|
||||
<div className={styles.filterGroup}>
|
||||
<label className={styles.filterLabel}>
|
||||
<FaBuilding style={{ marginRight: 8 }} />
|
||||
Mandant:
|
||||
</label>
|
||||
<select
|
||||
className={styles.filterSelect}
|
||||
value={selectedMandateId}
|
||||
onChange={(e) => setSelectedMandateId(e.target.value)}
|
||||
>
|
||||
<option value="">-- Mandant wählen --</option>
|
||||
{mandates.map(m => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{getMandateName(m)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterGroup}>
|
||||
<label className={styles.checkboxLabel}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showExpired}
|
||||
onChange={(e) => setShowExpired(e.target.checked)}
|
||||
/>
|
||||
Abgelaufene anzeigen
|
||||
</label>
|
||||
<label className={styles.checkboxLabel}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={showUsed}
|
||||
onChange={(e) => setShowUsed(e.target.checked)}
|
||||
/>
|
||||
Verwendete anzeigen
|
||||
</label>
|
||||
</div>
|
||||
|
||||
{selectedMandateId && (
|
||||
<div className={styles.headerActions}>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
onClick={() => fetchInvitations(selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed })}
|
||||
disabled={loading}
|
||||
>
|
||||
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
|
||||
</button>
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
disabled={roles.length === 0}
|
||||
>
|
||||
<FaPlus /> Neue Einladung
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{!selectedMandateId ? (
|
||||
<div className={styles.emptyState}>
|
||||
<FaBuilding className={styles.emptyIcon} />
|
||||
<h3 className={styles.emptyTitle}>Kein Mandant ausgewählt</h3>
|
||||
<p className={styles.emptyDescription}>
|
||||
Wählen Sie einen Mandanten aus, um dessen Einladungen zu verwalten.
|
||||
</p>
|
||||
</div>
|
||||
) : loading && invitations.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Lade Einladungen...</span>
|
||||
</div>
|
||||
) : invitations.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<FaEnvelopeOpenText className={styles.emptyIcon} />
|
||||
<h3 className={styles.emptyTitle}>Keine Einladungen</h3>
|
||||
<p className={styles.emptyDescription}>
|
||||
Es gibt noch keine aktiven Einladungen für diesen Mandanten.
|
||||
</p>
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
disabled={roles.length === 0}
|
||||
>
|
||||
<FaPlus /> Erste Einladung erstellen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.tableContainer}>
|
||||
<FormGeneratorTable
|
||||
data={invitations}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
pagination={true}
|
||||
pageSize={25}
|
||||
searchable={true}
|
||||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={false}
|
||||
actionButtons={[
|
||||
{
|
||||
type: 'delete' as const,
|
||||
title: 'Einladung widerrufen',
|
||||
}
|
||||
]}
|
||||
customActions={[
|
||||
{
|
||||
id: 'showUrl',
|
||||
icon: <FaLink />,
|
||||
onClick: handleShowUrl,
|
||||
title: 'Einladungs-Link anzeigen',
|
||||
}
|
||||
]}
|
||||
onDelete={handleRevokeInvitation}
|
||||
hookData={{
|
||||
refetch: () => fetchInvitations(selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed }),
|
||||
}}
|
||||
emptyMessage="Keine Einladungen gefunden"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Invitation Modal */}
|
||||
{showCreateModal && (
|
||||
<div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
|
||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||||
<div className={styles.modalHeader}>
|
||||
<h2 className={styles.modalTitle}>Neue Einladung erstellen</h2>
|
||||
<button
|
||||
className={styles.modalClose}
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.modalContent}>
|
||||
{roles.length === 0 ? (
|
||||
<p>Keine Rollen verfügbar. Erstellen Sie zuerst Rollen für diesen Mandanten.</p>
|
||||
) : createFields.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Lade Formular...</span>
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorForm
|
||||
attributes={createFields}
|
||||
mode="create"
|
||||
onSubmit={handleCreateInvitation}
|
||||
onCancel={() => setShowCreateModal(false)}
|
||||
submitButtonText="Einladung erstellen"
|
||||
cancelButtonText="Abbrechen"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* URL Display Modal */}
|
||||
{showUrlModal && (
|
||||
<div className={styles.modalOverlay} onClick={() => setShowUrlModal(null)}>
|
||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||||
<div className={styles.modalHeader}>
|
||||
<h2 className={styles.modalTitle}>Einladungs-Link</h2>
|
||||
<button
|
||||
className={styles.modalClose}
|
||||
onClick={() => setShowUrlModal(null)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.modalContent}>
|
||||
<p style={{ marginBottom: '1rem', color: 'var(--text-secondary)' }}>
|
||||
Teilen Sie diesen Link mit dem eingeladenen Benutzer:
|
||||
</p>
|
||||
<div className={styles.urlBox}>
|
||||
<input
|
||||
type="text"
|
||||
readOnly
|
||||
value={showUrlModal.inviteUrl}
|
||||
className={styles.urlInput}
|
||||
onClick={(e) => (e.target as HTMLInputElement).select()}
|
||||
/>
|
||||
<button
|
||||
className={styles.copyButton}
|
||||
onClick={() => handleCopyUrl(showUrlModal.inviteUrl)}
|
||||
title="In Zwischenablage kopieren"
|
||||
>
|
||||
<FaCopy />
|
||||
{copySuccess ? ' Kopiert!' : ' Kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
{showUrlModal.email && (
|
||||
<p style={{ marginTop: '1rem', fontSize: '0.875rem', color: 'var(--text-secondary)' }}>
|
||||
Dieser Link kann nur von <strong>{showUrlModal.email}</strong> verwendet werden.
|
||||
</p>
|
||||
)}
|
||||
<p style={{ marginTop: '0.5rem', fontSize: '0.875rem', color: 'var(--text-secondary)' }}>
|
||||
Gültig bis: {formatDate(showUrlModal.expiresAt)}
|
||||
</p>
|
||||
</div>
|
||||
<div className={styles.modalFooter}>
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={() => setShowUrlModal(null)}
|
||||
>
|
||||
Schliessen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminInvitationsPage;
|
||||
527
src/pages/admin/AdminMandateRolesPage.tsx
Normal file
527
src/pages/admin/AdminMandateRolesPage.tsx
Normal file
|
|
@ -0,0 +1,527 @@
|
|||
/**
|
||||
* AdminMandateRolesPage
|
||||
*
|
||||
* Admin page for managing roles within a specific mandate.
|
||||
* Allows creating, viewing, editing, and deleting mandate-level roles.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useMandateRoles, type Role, type RoleCreate, type RoleUpdate } from '../../hooks/useMandateRoles';
|
||||
import { useUserMandates, type Mandate } from '../../hooks/useUserMandates';
|
||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||
import { FaPlus, FaSync, FaUserShield, FaBuilding, FaGlobe, FaCube } from 'react-icons/fa';
|
||||
import api from '../../api';
|
||||
import styles from './Admin.module.css';
|
||||
|
||||
export const AdminMandateRolesPage: React.FC = () => {
|
||||
const {
|
||||
roles,
|
||||
loading,
|
||||
error,
|
||||
fetchRoles,
|
||||
createRole,
|
||||
updateRole,
|
||||
deleteRole,
|
||||
} = useMandateRoles();
|
||||
|
||||
const { fetchMandates } = useUserMandates();
|
||||
|
||||
// State
|
||||
const [mandates, setMandates] = useState<Mandate[]>([]);
|
||||
const [selectedMandateId, setSelectedMandateId] = useState<string>('');
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingRole, setEditingRole] = useState<Role | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [roleFilter, setRoleFilter] = useState<'all' | 'mandate' | 'global'>('all');
|
||||
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||
|
||||
// Load mandates and attributes on mount
|
||||
useEffect(() => {
|
||||
const loadMandates = async () => {
|
||||
const data = await fetchMandates();
|
||||
setMandates(data);
|
||||
if (data.length > 0 && !selectedMandateId) {
|
||||
setSelectedMandateId(data[0].id);
|
||||
}
|
||||
};
|
||||
loadMandates();
|
||||
// Fetch Role attributes from backend
|
||||
api.get('/api/attributes/Role').then(response => {
|
||||
const attrs = response.data?.attributes || response.data || [];
|
||||
setBackendAttributes(Array.isArray(attrs) ? attrs : []);
|
||||
}).catch(() => setBackendAttributes([]));
|
||||
}, [fetchMandates]);
|
||||
|
||||
// Load roles when mandate changes
|
||||
useEffect(() => {
|
||||
if (selectedMandateId) {
|
||||
fetchRoles(selectedMandateId);
|
||||
}
|
||||
}, [selectedMandateId, fetchRoles]);
|
||||
|
||||
// Filter roles based on selection and add scopeType field
|
||||
const filteredRoles = useMemo(() => {
|
||||
if (!selectedMandateId) return [];
|
||||
|
||||
return roles
|
||||
.filter(role => {
|
||||
// Don't show feature-instance level roles here
|
||||
if (role.featureInstanceId) return false;
|
||||
|
||||
switch (roleFilter) {
|
||||
case 'mandate':
|
||||
return role.mandateId === selectedMandateId;
|
||||
case 'global':
|
||||
return !role.mandateId;
|
||||
default:
|
||||
return !role.mandateId || role.mandateId === selectedMandateId;
|
||||
}
|
||||
})
|
||||
.map(role => ({
|
||||
...role,
|
||||
// Computed field for table display - not an ID/boolean type
|
||||
scopeType: role.isSystemRole ? 'system' : (role.mandateId ? 'mandate' : 'global')
|
||||
}));
|
||||
}, [roles, selectedMandateId, roleFilter]);
|
||||
|
||||
// Get description text
|
||||
const getDescriptionText = (desc: string | { [key: string]: string } | undefined) => {
|
||||
if (!desc) return '-';
|
||||
if (typeof desc === 'string') return desc;
|
||||
return desc.de || desc.en || Object.values(desc)[0] || '-';
|
||||
};
|
||||
|
||||
// Table columns
|
||||
const columns = useMemo(() => [
|
||||
{
|
||||
key: 'roleLabel',
|
||||
label: 'Bezeichnung',
|
||||
type: 'string' as const,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
width: 150
|
||||
},
|
||||
{
|
||||
key: 'description',
|
||||
label: 'Beschreibung',
|
||||
type: 'string' as const,
|
||||
sortable: false,
|
||||
width: 250,
|
||||
formatter: (value: string | { [key: string]: string }) => getDescriptionText(value)
|
||||
},
|
||||
{
|
||||
key: 'scopeType',
|
||||
label: 'Typ',
|
||||
type: 'string' as const,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 120,
|
||||
formatter: (value: string) => {
|
||||
if (value === 'system') {
|
||||
return (
|
||||
<span className={styles.badge} style={{ background: 'var(--warning-color, #d69e2e)', color: 'white' }}>
|
||||
<FaUserShield style={{ marginRight: 4 }} /> System
|
||||
</span>
|
||||
);
|
||||
}
|
||||
if (value === 'global') {
|
||||
return (
|
||||
<span className={styles.badge} style={{ background: 'var(--info-color, #3182ce)', color: 'white' }}>
|
||||
<FaGlobe style={{ marginRight: 4 }} /> Global
|
||||
</span>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<span className={styles.badge} style={{ background: 'var(--success-color, #38a169)', color: 'white' }}>
|
||||
<FaBuilding style={{ marginRight: 4 }} /> Mandant
|
||||
</span>
|
||||
);
|
||||
}
|
||||
},
|
||||
{
|
||||
key: 'featureCode',
|
||||
label: 'Feature',
|
||||
type: 'string' as const,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
width: 120,
|
||||
formatter: (value: string) => value ? (
|
||||
<span className={styles.badge} style={{ background: 'var(--bg-secondary)' }}>
|
||||
<FaCube style={{ marginRight: 4 }} /> {value}
|
||||
</span>
|
||||
) : '-'
|
||||
},
|
||||
], []);
|
||||
|
||||
// Form attributes from backend - for create form
|
||||
const createFields: AttributeDefinition[] = useMemo(() => {
|
||||
const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'featureCode', 'isSystemRole'];
|
||||
|
||||
const fields = backendAttributes
|
||||
.filter(attr => !excludedFields.includes(attr.name))
|
||||
.map(attr => ({ ...attr })) as AttributeDefinition[];
|
||||
|
||||
// Add scope field for mandate/global selection (not a model attribute)
|
||||
if (fields.length > 0) {
|
||||
fields.push({
|
||||
name: 'scope',
|
||||
label: 'Geltungsbereich',
|
||||
type: 'enum' as any,
|
||||
required: true,
|
||||
default: 'mandate',
|
||||
options: [
|
||||
{ value: 'mandate', label: 'Nur dieser Mandant' },
|
||||
{ value: 'global', label: 'Global (alle Mandanten)' },
|
||||
]
|
||||
});
|
||||
}
|
||||
return fields;
|
||||
}, [backendAttributes]);
|
||||
|
||||
// Form attributes from backend - for edit form
|
||||
const editFields: AttributeDefinition[] = useMemo(() => {
|
||||
const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'featureCode', 'isSystemRole'];
|
||||
|
||||
const fields = backendAttributes
|
||||
.filter(attr => !excludedFields.includes(attr.name))
|
||||
.map(attr => ({
|
||||
...attr,
|
||||
// Mark roleLabel as readonly for system roles
|
||||
readonly: attr.name === 'roleLabel' && editingRole?.isSystemRole ? true : attr.readonly,
|
||||
})) as AttributeDefinition[];
|
||||
|
||||
// Add scope field for mandate/global selection (only if not system role)
|
||||
if (fields.length > 0 && !editingRole?.isSystemRole) {
|
||||
fields.push({
|
||||
name: 'scope',
|
||||
label: 'Geltungsbereich',
|
||||
type: 'enum' as any,
|
||||
required: true,
|
||||
options: [
|
||||
{ value: 'mandate', label: 'Nur dieser Mandant' },
|
||||
{ value: 'global', label: 'Global (alle Mandanten)' },
|
||||
]
|
||||
});
|
||||
}
|
||||
return fields;
|
||||
}, [backendAttributes, editingRole]);
|
||||
|
||||
// Handle create role
|
||||
const handleCreateRole = async (data: { roleLabel: string; description?: string; scope: 'mandate' | 'global' }) => {
|
||||
if (!selectedMandateId) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const roleData: RoleCreate = {
|
||||
roleLabel: data.roleLabel.toLowerCase().replace(/\s+/g, '_'),
|
||||
description: data.description,
|
||||
mandateId: data.scope === 'mandate' ? selectedMandateId : undefined
|
||||
};
|
||||
|
||||
const result = await createRole(roleData, selectedMandateId);
|
||||
if (result.success) {
|
||||
setShowCreateModal(false);
|
||||
fetchRoles(selectedMandateId);
|
||||
} else {
|
||||
alert(result.error || 'Fehler beim Erstellen der Rolle');
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle edit role
|
||||
const handleEditRole = async (data: RoleUpdate & { scope?: 'mandate' | 'global' }) => {
|
||||
if (!editingRole) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// Convert scope to mandateId
|
||||
const updateData: RoleUpdate = {
|
||||
...data,
|
||||
mandateId: data.scope === 'mandate' ? selectedMandateId : null,
|
||||
};
|
||||
// Remove scope field as it's not part of the model
|
||||
delete (updateData as any).scope;
|
||||
|
||||
const result = await updateRole(editingRole.id, updateData);
|
||||
if (result.success) {
|
||||
setEditingRole(null);
|
||||
if (selectedMandateId) {
|
||||
fetchRoles(selectedMandateId);
|
||||
}
|
||||
} else {
|
||||
alert(result.error || 'Fehler beim Aktualisieren der Rolle');
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle delete role
|
||||
const handleDeleteRole = async (role: Role) => {
|
||||
if (role.isSystemRole) {
|
||||
alert('System-Rollen können nicht gelöscht werden.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (window.confirm(`Möchten Sie die Rolle "${role.roleLabel}" wirklich löschen?`)) {
|
||||
const result = await deleteRole(role.id);
|
||||
if (!result.success) {
|
||||
alert(result.error || 'Fehler beim Löschen der Rolle');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle edit click
|
||||
const handleEditClick = (role: Role) => {
|
||||
setEditingRole(role);
|
||||
};
|
||||
|
||||
// Get mandate name
|
||||
const getMandateName = (mandate: Mandate) => {
|
||||
if (typeof mandate.name === 'object') {
|
||||
return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id;
|
||||
}
|
||||
return mandate.name || mandate.id;
|
||||
};
|
||||
|
||||
if (error && !selectedMandateId) {
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={styles.errorContainer}>
|
||||
<span className={styles.errorIcon}>⚠️</span>
|
||||
<p className={styles.errorMessage}>Fehler: {error}</p>
|
||||
<button className={styles.secondaryButton} onClick={() => fetchMandates()}>
|
||||
<FaSync /> Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h1 className={styles.pageTitle}>Mandanten-Rollen</h1>
|
||||
<p className={styles.pageSubtitle}>Verwalten Sie Rollen innerhalb eines Mandanten</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mandate Selector and Filters */}
|
||||
<div className={styles.filterSection}>
|
||||
<div className={styles.filterGroup}>
|
||||
<label className={styles.filterLabel}>
|
||||
<FaBuilding style={{ marginRight: 8 }} />
|
||||
Mandant:
|
||||
</label>
|
||||
<select
|
||||
className={styles.filterSelect}
|
||||
value={selectedMandateId}
|
||||
onChange={(e) => setSelectedMandateId(e.target.value)}
|
||||
>
|
||||
<option value="">-- Mandant wählen --</option>
|
||||
{mandates.map(m => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{getMandateName(m)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className={styles.filterGroup}>
|
||||
<label className={styles.filterLabel}>Filter:</label>
|
||||
<select
|
||||
className={styles.filterSelect}
|
||||
value={roleFilter}
|
||||
onChange={(e) => setRoleFilter(e.target.value as 'all' | 'mandate' | 'global')}
|
||||
style={{ minWidth: 150 }}
|
||||
>
|
||||
<option value="all">Alle Rollen</option>
|
||||
<option value="mandate">Nur Mandanten-Rollen</option>
|
||||
<option value="global">Nur globale Rollen</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedMandateId && (
|
||||
<div className={styles.headerActions}>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
onClick={() => fetchRoles(selectedMandateId)}
|
||||
disabled={loading}
|
||||
>
|
||||
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
|
||||
</button>
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
>
|
||||
<FaPlus /> Neue Rolle
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info Box */}
|
||||
{selectedMandateId && (
|
||||
<div className={styles.infoBox}>
|
||||
<FaUserShield style={{ marginRight: 8 }} />
|
||||
<span>
|
||||
<strong>Globale Rollen</strong> gelten für alle Mandanten.
|
||||
<strong> Mandanten-Rollen</strong> gelten nur für den ausgewählten Mandanten.
|
||||
<strong> System-Rollen</strong> (sysadmin) können nicht bearbeitet werden.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Content */}
|
||||
{!selectedMandateId ? (
|
||||
<div className={styles.emptyState}>
|
||||
<FaBuilding className={styles.emptyIcon} />
|
||||
<h3 className={styles.emptyTitle}>Kein Mandant ausgewählt</h3>
|
||||
<p className={styles.emptyDescription}>
|
||||
Wählen Sie einen Mandanten aus, um dessen Rollen zu verwalten.
|
||||
</p>
|
||||
</div>
|
||||
) : loading && filteredRoles.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Lade Rollen...</span>
|
||||
</div>
|
||||
) : filteredRoles.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<FaUserShield className={styles.emptyIcon} />
|
||||
<h3 className={styles.emptyTitle}>Keine Rollen</h3>
|
||||
<p className={styles.emptyDescription}>
|
||||
{roleFilter === 'mandate'
|
||||
? 'Es gibt noch keine mandantenspezifischen Rollen.'
|
||||
: roleFilter === 'global'
|
||||
? 'Es gibt noch keine globalen Rollen.'
|
||||
: 'Es gibt noch keine Rollen für diesen Mandanten.'}
|
||||
</p>
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={() => setShowCreateModal(true)}
|
||||
>
|
||||
<FaPlus /> Erste Rolle erstellen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.tableContainer}>
|
||||
<FormGeneratorTable
|
||||
data={filteredRoles}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
pagination={true}
|
||||
pageSize={25}
|
||||
searchable={true}
|
||||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={false}
|
||||
actionButtons={[
|
||||
{
|
||||
type: 'edit' as const,
|
||||
onAction: handleEditClick,
|
||||
title: 'Rolle bearbeiten',
|
||||
},
|
||||
{
|
||||
type: 'delete' as const,
|
||||
title: 'Rolle löschen',
|
||||
disabled: (row: Role) => row.isSystemRole ? { disabled: true, message: 'System-Rollen können nicht gelöscht werden' } : false
|
||||
}
|
||||
]}
|
||||
onDelete={handleDeleteRole}
|
||||
hookData={{
|
||||
refetch: () => fetchRoles(selectedMandateId),
|
||||
handleDelete: handleDeleteRole,
|
||||
}}
|
||||
emptyMessage="Keine Rollen gefunden"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Create Role Modal */}
|
||||
{showCreateModal && (
|
||||
<div className={styles.modalOverlay} onClick={() => setShowCreateModal(false)}>
|
||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||||
<div className={styles.modalHeader}>
|
||||
<h2 className={styles.modalTitle}>Neue Rolle erstellen</h2>
|
||||
<button
|
||||
className={styles.modalClose}
|
||||
onClick={() => setShowCreateModal(false)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.modalContent}>
|
||||
{createFields.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Lade Formular...</span>
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorForm
|
||||
attributes={createFields}
|
||||
mode="create"
|
||||
onSubmit={handleCreateRole}
|
||||
onCancel={() => setShowCreateModal(false)}
|
||||
submitButtonText="Rolle erstellen"
|
||||
cancelButtonText="Abbrechen"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Role Modal */}
|
||||
{editingRole && (
|
||||
<div className={styles.modalOverlay} onClick={() => setEditingRole(null)}>
|
||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||||
<div className={styles.modalHeader}>
|
||||
<h2 className={styles.modalTitle}>Rolle bearbeiten: {editingRole.roleLabel}</h2>
|
||||
<button
|
||||
className={styles.modalClose}
|
||||
onClick={() => setEditingRole(null)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.modalContent}>
|
||||
{editFields.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Lade Formular...</span>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{editingRole.isSystemRole && (
|
||||
<div className={styles.infoBox} style={{ marginBottom: '1rem' }}>
|
||||
<FaUserShield style={{ marginRight: 8 }} />
|
||||
<span>System-Rollen können nur eingeschränkt bearbeitet werden.</span>
|
||||
</div>
|
||||
)}
|
||||
<FormGeneratorForm
|
||||
attributes={editFields}
|
||||
data={{
|
||||
...editingRole,
|
||||
scope: editingRole.mandateId ? 'mandate' : 'global'
|
||||
}}
|
||||
mode="edit"
|
||||
onSubmit={handleEditRole}
|
||||
onCancel={() => setEditingRole(null)}
|
||||
submitButtonText="Speichern"
|
||||
cancelButtonText="Abbrechen"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminMandateRolesPage;
|
||||
|
|
@ -4,16 +4,17 @@
|
|||
* Admin page for managing Mandates (tenants) using FormGeneratorTable.
|
||||
*/
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import React, { useState, useMemo } from 'react';
|
||||
import { useAdminMandates, type Mandate } from '../../hooks/useMandates';
|
||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
||||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||
import { FaPlus, FaSync, FaBuilding } from 'react-icons/fa';
|
||||
import styles from './Admin.module.css';
|
||||
|
||||
export const AdminMandatesPage: React.FC = () => {
|
||||
const {
|
||||
mandates,
|
||||
attributes,
|
||||
columns,
|
||||
permissions,
|
||||
pagination,
|
||||
|
|
@ -28,9 +29,19 @@ export const AdminMandatesPage: React.FC = () => {
|
|||
updateOptimistically,
|
||||
} = useAdminMandates();
|
||||
|
||||
// Form attributes from backend - filter for create/edit forms
|
||||
const formAttributes: AttributeDefinition[] = useMemo(() => {
|
||||
const excludedFields = ['id'];
|
||||
return attributes
|
||||
.filter(attr => !excludedFields.includes(attr.name))
|
||||
.map(attr => ({
|
||||
...attr,
|
||||
type: attr.type,
|
||||
})) as AttributeDefinition[];
|
||||
}, [attributes]);
|
||||
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingMandate, setEditingMandate] = useState<Mandate | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Check if user can create
|
||||
const canCreate = permissions?.create !== 'n';
|
||||
|
|
@ -47,28 +58,18 @@ export const AdminMandatesPage: React.FC = () => {
|
|||
|
||||
// Handle create submit
|
||||
const handleCreateSubmit = async (data: Partial<Mandate>) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const success = await handleCreate(data);
|
||||
if (success) {
|
||||
setShowCreateModal(false);
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
const success = await handleCreate(data);
|
||||
if (success) {
|
||||
setShowCreateModal(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle edit submit
|
||||
const handleEditSubmit = async (data: Partial<Mandate>) => {
|
||||
if (!editingMandate) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const success = await handleUpdate(editingMandate.id, data);
|
||||
if (success) {
|
||||
setEditingMandate(null);
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
const success = await handleUpdate(editingMandate.id, data);
|
||||
if (success) {
|
||||
setEditingMandate(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -191,18 +192,21 @@ export const AdminMandatesPage: React.FC = () => {
|
|||
</button>
|
||||
</div>
|
||||
<div className={styles.modalContent}>
|
||||
<FormGeneratorForm
|
||||
fields={[
|
||||
{ key: 'name', label: 'Name', type: 'string', required: true },
|
||||
{ key: 'description', label: 'Beschreibung', type: 'textarea' },
|
||||
{ key: 'enabled', label: 'Aktiv', type: 'boolean' },
|
||||
]}
|
||||
onSubmit={handleCreateSubmit}
|
||||
onCancel={() => setShowCreateModal(false)}
|
||||
submitLabel="Erstellen"
|
||||
cancelLabel="Abbrechen"
|
||||
loading={isSubmitting}
|
||||
/>
|
||||
{formAttributes.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Lade Formular...</span>
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorForm
|
||||
attributes={formAttributes}
|
||||
mode="create"
|
||||
onSubmit={handleCreateSubmit}
|
||||
onCancel={() => setShowCreateModal(false)}
|
||||
submitButtonText="Erstellen"
|
||||
cancelButtonText="Abbrechen"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -222,19 +226,22 @@ export const AdminMandatesPage: React.FC = () => {
|
|||
</button>
|
||||
</div>
|
||||
<div className={styles.modalContent}>
|
||||
<FormGeneratorForm
|
||||
fields={[
|
||||
{ key: 'name', label: 'Name', type: 'string', required: true },
|
||||
{ key: 'description', label: 'Beschreibung', type: 'textarea' },
|
||||
{ key: 'enabled', label: 'Aktiv', type: 'boolean' },
|
||||
]}
|
||||
initialData={editingMandate}
|
||||
onSubmit={handleEditSubmit}
|
||||
onCancel={() => setEditingMandate(null)}
|
||||
submitLabel="Speichern"
|
||||
cancelLabel="Abbrechen"
|
||||
loading={isSubmitting}
|
||||
/>
|
||||
{formAttributes.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Lade Formular...</span>
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorForm
|
||||
attributes={formAttributes}
|
||||
data={editingMandate}
|
||||
mode="edit"
|
||||
onSubmit={handleEditSubmit}
|
||||
onCancel={() => setEditingMandate(null)}
|
||||
submitButtonText="Speichern"
|
||||
cancelButtonText="Abbrechen"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,13 +7,14 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import { useAdminRoles, type Role } from '../../hooks/useRoles';
|
||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm';
|
||||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||
import { FaPlus, FaSync, FaUserShield } from 'react-icons/fa';
|
||||
import styles from './Admin.module.css';
|
||||
|
||||
export const AdminRolesPage: React.FC = () => {
|
||||
const {
|
||||
roles,
|
||||
attributes,
|
||||
columns,
|
||||
permissions,
|
||||
pagination,
|
||||
|
|
@ -32,15 +33,11 @@ export const AdminRolesPage: React.FC = () => {
|
|||
const [editingRole, setEditingRole] = useState<Role | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Ensure columns have at least default values
|
||||
// Columns for global roles - exclude mandate/feature-specific columns
|
||||
// Global roles don't have mandate or feature instance associations
|
||||
const displayColumns = useMemo(() => {
|
||||
if (columns.length > 0) return columns;
|
||||
|
||||
// Default columns if none from backend
|
||||
return [
|
||||
{ key: 'roleLabel', label: 'Rollen-Label', type: 'string' as const, sortable: true, filterable: true, searchable: true, width: 150 },
|
||||
{ key: 'description', label: 'Beschreibung', type: 'string' as const, sortable: true, filterable: false, searchable: true, width: 300 },
|
||||
];
|
||||
const excludedColumns = ['mandateId', 'featureInstanceId', 'featureCode'];
|
||||
return columns.filter(col => !excludedColumns.includes(col.key));
|
||||
}, [columns]);
|
||||
|
||||
// Check permissions
|
||||
|
|
@ -60,15 +57,7 @@ export const AdminRolesPage: React.FC = () => {
|
|||
const handleCreateSubmit = async (data: Partial<Role>) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// Transform description to TextMultilingual format if it's a string
|
||||
const roleData: Partial<Role> = {
|
||||
...data,
|
||||
description: typeof data.description === 'string'
|
||||
? { en: data.description }
|
||||
: data.description,
|
||||
};
|
||||
|
||||
const success = await handleCreate(roleData);
|
||||
const success = await handleCreate(data);
|
||||
if (success) {
|
||||
setShowCreateModal(false);
|
||||
}
|
||||
|
|
@ -82,15 +71,7 @@ export const AdminRolesPage: React.FC = () => {
|
|||
if (!editingRole) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
// Transform description to TextMultilingual format if it's a string
|
||||
const roleData: Partial<Role> = {
|
||||
...data,
|
||||
description: typeof data.description === 'string'
|
||||
? { en: data.description }
|
||||
: data.description,
|
||||
};
|
||||
|
||||
const success = await handleUpdate(editingRole.id, roleData);
|
||||
const success = await handleUpdate(editingRole.id, data);
|
||||
if (success) {
|
||||
setEditingRole(null);
|
||||
}
|
||||
|
|
@ -106,11 +87,18 @@ export const AdminRolesPage: React.FC = () => {
|
|||
}
|
||||
};
|
||||
|
||||
// Form fields for create/edit
|
||||
const formFields = useMemo(() => [
|
||||
{ key: 'roleLabel', label: 'Rollen-Label', type: 'string' as const, required: true },
|
||||
{ key: 'description', label: 'Beschreibung', type: 'textarea' as const },
|
||||
], []);
|
||||
// Form attributes from backend - filter for create/edit forms
|
||||
// Exclude fields not relevant for global roles (mandateId, featureInstanceId, etc.)
|
||||
const formAttributes: AttributeDefinition[] = useMemo(() => {
|
||||
const excludedFields = ['id', 'mandateId', 'featureInstanceId', 'featureCode', 'isSystemRole'];
|
||||
return attributes
|
||||
.filter(attr => !excludedFields.includes(attr.name))
|
||||
.map(attr => ({
|
||||
...attr,
|
||||
// Map backend type names to form types
|
||||
type: attr.type === 'multilingual' ? 'multilingual' : attr.type,
|
||||
})) as AttributeDefinition[];
|
||||
}, [attributes]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
|
|
@ -224,14 +212,21 @@ export const AdminRolesPage: React.FC = () => {
|
|||
</button>
|
||||
</div>
|
||||
<div className={styles.modalContent}>
|
||||
<FormGeneratorForm
|
||||
fields={formFields}
|
||||
onSubmit={handleCreateSubmit}
|
||||
onCancel={() => setShowCreateModal(false)}
|
||||
submitLabel="Erstellen"
|
||||
cancelLabel="Abbrechen"
|
||||
loading={isSubmitting}
|
||||
/>
|
||||
{formAttributes.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Lade Formular...</span>
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorForm
|
||||
attributes={formAttributes}
|
||||
mode="create"
|
||||
onSubmit={handleCreateSubmit}
|
||||
onCancel={() => setShowCreateModal(false)}
|
||||
submitButtonText={isSubmitting ? 'Erstelle...' : 'Erstellen'}
|
||||
cancelButtonText="Abbrechen"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -251,21 +246,22 @@ export const AdminRolesPage: React.FC = () => {
|
|||
</button>
|
||||
</div>
|
||||
<div className={styles.modalContent}>
|
||||
<FormGeneratorForm
|
||||
fields={formFields}
|
||||
initialData={{
|
||||
...editingRole,
|
||||
// Extract description from TextMultilingual if needed
|
||||
description: typeof editingRole.description === 'object'
|
||||
? editingRole.description?.en || editingRole.description?.ge || ''
|
||||
: editingRole.description,
|
||||
}}
|
||||
onSubmit={handleEditSubmit}
|
||||
onCancel={() => setEditingRole(null)}
|
||||
submitLabel="Speichern"
|
||||
cancelLabel="Abbrechen"
|
||||
loading={isSubmitting}
|
||||
/>
|
||||
{formAttributes.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Lade Formular...</span>
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorForm
|
||||
attributes={formAttributes}
|
||||
data={editingRole}
|
||||
mode="edit"
|
||||
onSubmit={handleEditSubmit}
|
||||
onCancel={() => setEditingRole(null)}
|
||||
submitButtonText={isSubmitting ? 'Speichern...' : 'Speichern'}
|
||||
cancelButtonText="Abbrechen"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
442
src/pages/admin/AdminUserMandatesPage.tsx
Normal file
442
src/pages/admin/AdminUserMandatesPage.tsx
Normal file
|
|
@ -0,0 +1,442 @@
|
|||
/**
|
||||
* AdminUserMandatesPage
|
||||
*
|
||||
* Admin page for managing user-mandate memberships.
|
||||
* Allows assigning users to mandates and managing their roles within mandates.
|
||||
*/
|
||||
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import { useUserMandates, type MandateUser, type Mandate, type Role } from '../../hooks/useUserMandates';
|
||||
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable';
|
||||
import { FormGeneratorForm, type AttributeDefinition } from '../../components/FormGenerator/FormGeneratorForm';
|
||||
import { FaPlus, FaSync, FaUsers, FaBuilding } from 'react-icons/fa';
|
||||
import api from '../../api';
|
||||
import styles from './Admin.module.css';
|
||||
|
||||
export const AdminUserMandatesPage: React.FC = () => {
|
||||
const {
|
||||
users,
|
||||
loading,
|
||||
error,
|
||||
fetchMandateUsers,
|
||||
addUserToMandate,
|
||||
removeUserFromMandate,
|
||||
updateUserRoles,
|
||||
fetchMandates,
|
||||
fetchRoles,
|
||||
fetchAllUsers,
|
||||
} = useUserMandates();
|
||||
|
||||
// State
|
||||
const [mandates, setMandates] = useState<Mandate[]>([]);
|
||||
const [selectedMandateId, setSelectedMandateId] = useState<string>('');
|
||||
const [roles, setRoles] = useState<Role[]>([]);
|
||||
const [allUsers, setAllUsers] = useState<Array<{ id: string; username: string; email?: string; fullName?: string }>>([]);
|
||||
const [showAddModal, setShowAddModal] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<MandateUser | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
const [backendAttributes, setBackendAttributes] = useState<AttributeDefinition[]>([]);
|
||||
|
||||
// Load mandates and attributes on mount
|
||||
useEffect(() => {
|
||||
const loadMandates = async () => {
|
||||
const data = await fetchMandates();
|
||||
setMandates(data);
|
||||
// Auto-select first mandate if available
|
||||
if (data.length > 0 && !selectedMandateId) {
|
||||
setSelectedMandateId(data[0].id);
|
||||
}
|
||||
};
|
||||
loadMandates();
|
||||
// Fetch UserMandate attributes from backend (for table columns)
|
||||
api.get('/api/attributes/UserMandate').then(response => {
|
||||
const attrs = response.data?.attributes || response.data || [];
|
||||
setBackendAttributes(Array.isArray(attrs) ? attrs : []);
|
||||
}).catch(() => setBackendAttributes([]));
|
||||
}, [fetchMandates]);
|
||||
|
||||
// Load users when mandate changes
|
||||
useEffect(() => {
|
||||
if (selectedMandateId) {
|
||||
fetchMandateUsers(selectedMandateId);
|
||||
fetchRoles(selectedMandateId).then(setRoles);
|
||||
}
|
||||
}, [selectedMandateId, fetchMandateUsers, fetchRoles]);
|
||||
|
||||
// Load all users for the add modal
|
||||
useEffect(() => {
|
||||
fetchAllUsers().then(setAllUsers);
|
||||
}, [fetchAllUsers]);
|
||||
|
||||
// Get users not yet in the mandate
|
||||
const availableUsers = useMemo(() => {
|
||||
const existingUserIds = new Set(users.map(u => u.userId));
|
||||
return allUsers.filter(u => !existingUserIds.has(u.id));
|
||||
}, [allUsers, users]);
|
||||
|
||||
// Table columns - based on MandateUserInfo response structure
|
||||
const columns = useMemo(() => {
|
||||
return [
|
||||
{
|
||||
key: 'username',
|
||||
label: 'Benutzername',
|
||||
type: 'text' as any,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
width: 150,
|
||||
},
|
||||
{
|
||||
key: 'email',
|
||||
label: 'E-Mail',
|
||||
type: 'text' as any,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
width: 200,
|
||||
},
|
||||
{
|
||||
key: 'fullName',
|
||||
label: 'Vollständiger Name',
|
||||
type: 'text' as any,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: true,
|
||||
width: 180,
|
||||
},
|
||||
{
|
||||
key: 'roleLabels',
|
||||
label: 'Rollen',
|
||||
type: 'text' as any,
|
||||
sortable: false,
|
||||
filterable: false,
|
||||
searchable: true,
|
||||
width: 200,
|
||||
render: (value: string[]) => {
|
||||
if (!value || value.length === 0) return '-';
|
||||
return value.join(', ');
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'enabled',
|
||||
label: 'Aktiv',
|
||||
type: 'boolean' as any,
|
||||
sortable: true,
|
||||
filterable: true,
|
||||
searchable: false,
|
||||
width: 80,
|
||||
},
|
||||
];
|
||||
}, []); // No dependencies - columns are static, roleLabels come from backend
|
||||
|
||||
// Dynamic options for forms (users and roles)
|
||||
const userOptions = useMemo(() =>
|
||||
availableUsers.map(u => ({
|
||||
value: u.id,
|
||||
label: `${u.username} ${u.email ? `(${u.email})` : ''}`
|
||||
})), [availableUsers]);
|
||||
|
||||
const roleOptions = useMemo(() =>
|
||||
roles.filter(r => !r.featureInstanceId).map(r => ({
|
||||
value: r.id,
|
||||
label: r.roleLabel
|
||||
})), [roles]);
|
||||
|
||||
// Form attributes for adding a user - uses dynamic options
|
||||
// Note: This is an operational form for junction table, not direct model editing
|
||||
const addUserFields: AttributeDefinition[] = useMemo(() => {
|
||||
// Check if backend has userId attribute to get label/description
|
||||
const userIdAttr = backendAttributes.find(a => a.name === 'userId');
|
||||
const roleIdsAttr = backendAttributes.find(a => a.name === 'roleIds');
|
||||
|
||||
return [
|
||||
{
|
||||
name: 'targetUserId',
|
||||
label: userIdAttr?.label || 'Benutzer',
|
||||
type: 'enum' as any,
|
||||
required: true,
|
||||
options: userOptions,
|
||||
},
|
||||
{
|
||||
name: 'roleIds',
|
||||
label: roleIdsAttr?.label || 'Rollen',
|
||||
type: 'multiselect' as any,
|
||||
required: true,
|
||||
options: roleOptions,
|
||||
}
|
||||
];
|
||||
}, [userOptions, roleOptions, backendAttributes]);
|
||||
|
||||
// Form attributes for editing user roles
|
||||
const editRolesFields: AttributeDefinition[] = useMemo(() => {
|
||||
const roleIdsAttr = backendAttributes.find(a => a.name === 'roleIds');
|
||||
|
||||
return [{
|
||||
name: 'roleIds',
|
||||
label: roleIdsAttr?.label || 'Rollen',
|
||||
type: 'multiselect' as any,
|
||||
required: true,
|
||||
options: roleOptions,
|
||||
}];
|
||||
}, [roleOptions, backendAttributes]);
|
||||
|
||||
// Handle add user submit
|
||||
const handleAddUser = async (data: { targetUserId: string; roleIds: string[] }) => {
|
||||
if (!selectedMandateId) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await addUserToMandate(selectedMandateId, data);
|
||||
if (result.success) {
|
||||
setShowAddModal(false);
|
||||
fetchMandateUsers(selectedMandateId);
|
||||
} else {
|
||||
alert(result.error || 'Fehler beim Hinzufügen des Benutzers');
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle edit roles submit
|
||||
const handleEditRoles = async (data: { roleIds: string[] }) => {
|
||||
if (!selectedMandateId || !editingUser) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await updateUserRoles(selectedMandateId, editingUser.userId, data.roleIds);
|
||||
if (result.success) {
|
||||
setEditingUser(null);
|
||||
fetchMandateUsers(selectedMandateId);
|
||||
} else {
|
||||
alert(result.error || 'Fehler beim Aktualisieren der Rollen');
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// Handle remove user
|
||||
const handleRemoveUser = async (user: MandateUser) => {
|
||||
if (!selectedMandateId) return;
|
||||
if (window.confirm(`Möchten Sie den Benutzer "${user.username}" wirklich aus diesem Mandanten entfernen?`)) {
|
||||
const result = await removeUserFromMandate(selectedMandateId, user.userId);
|
||||
if (!result.success) {
|
||||
alert(result.error || 'Fehler beim Entfernen des Benutzers');
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// Handle edit click
|
||||
const handleEditClick = (user: MandateUser) => {
|
||||
setEditingUser(user);
|
||||
};
|
||||
|
||||
// Get mandate name
|
||||
const getMandateName = (mandate: Mandate) => {
|
||||
if (typeof mandate.name === 'object') {
|
||||
return mandate.name.de || mandate.name.en || Object.values(mandate.name)[0] || mandate.id;
|
||||
}
|
||||
return mandate.name || mandate.id;
|
||||
};
|
||||
|
||||
if (error && !selectedMandateId) {
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={styles.errorContainer}>
|
||||
<span className={styles.errorIcon}>⚠️</span>
|
||||
<p className={styles.errorMessage}>Fehler: {error}</p>
|
||||
<button className={styles.secondaryButton} onClick={() => fetchMandates()}>
|
||||
<FaSync /> Erneut versuchen
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={styles.adminPage}>
|
||||
<div className={styles.pageHeader}>
|
||||
<div>
|
||||
<h1 className={styles.pageTitle}>Mandanten-Mitglieder</h1>
|
||||
<p className={styles.pageSubtitle}>Verwalten Sie, welche Benutzer Zugriff auf welche Mandanten haben</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mandate Selector */}
|
||||
<div className={styles.filterSection}>
|
||||
<div className={styles.filterGroup}>
|
||||
<label className={styles.filterLabel}>
|
||||
<FaBuilding style={{ marginRight: 8 }} />
|
||||
Mandant auswählen:
|
||||
</label>
|
||||
<select
|
||||
className={styles.filterSelect}
|
||||
value={selectedMandateId}
|
||||
onChange={(e) => setSelectedMandateId(e.target.value)}
|
||||
>
|
||||
<option value="">-- Mandant wählen --</option>
|
||||
{mandates.map(m => (
|
||||
<option key={m.id} value={m.id}>
|
||||
{getMandateName(m)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
{selectedMandateId && (
|
||||
<div className={styles.headerActions}>
|
||||
<button
|
||||
className={styles.secondaryButton}
|
||||
onClick={() => fetchMandateUsers(selectedMandateId)}
|
||||
disabled={loading}
|
||||
>
|
||||
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
|
||||
</button>
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={() => setShowAddModal(true)}
|
||||
disabled={availableUsers.length === 0}
|
||||
>
|
||||
<FaPlus /> Benutzer hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
{!selectedMandateId ? (
|
||||
<div className={styles.emptyState}>
|
||||
<FaBuilding className={styles.emptyIcon} />
|
||||
<h3 className={styles.emptyTitle}>Kein Mandant ausgewählt</h3>
|
||||
<p className={styles.emptyDescription}>
|
||||
Wählen Sie einen Mandanten aus, um dessen Mitglieder zu verwalten.
|
||||
</p>
|
||||
</div>
|
||||
) : loading && users.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Lade Mandanten-Mitglieder...</span>
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className={styles.emptyState}>
|
||||
<FaUsers className={styles.emptyIcon} />
|
||||
<h3 className={styles.emptyTitle}>Keine Mitglieder</h3>
|
||||
<p className={styles.emptyDescription}>
|
||||
Diesem Mandanten sind noch keine Benutzer zugewiesen.
|
||||
</p>
|
||||
<button
|
||||
className={styles.primaryButton}
|
||||
onClick={() => setShowAddModal(true)}
|
||||
disabled={availableUsers.length === 0}
|
||||
>
|
||||
<FaPlus /> Ersten Benutzer hinzufügen
|
||||
</button>
|
||||
</div>
|
||||
) : (
|
||||
<div className={styles.tableContainer}>
|
||||
<FormGeneratorTable
|
||||
data={users}
|
||||
columns={columns}
|
||||
loading={loading}
|
||||
pagination={true}
|
||||
pageSize={25}
|
||||
searchable={true}
|
||||
filterable={true}
|
||||
sortable={true}
|
||||
selectable={false}
|
||||
actionButtons={[
|
||||
{
|
||||
type: 'edit' as const,
|
||||
onAction: handleEditClick,
|
||||
title: 'Rollen bearbeiten',
|
||||
},
|
||||
{
|
||||
type: 'delete' as const,
|
||||
title: 'Aus Mandant entfernen',
|
||||
}
|
||||
]}
|
||||
onDelete={handleRemoveUser}
|
||||
hookData={{
|
||||
refetch: () => fetchMandateUsers(selectedMandateId),
|
||||
handleDelete: async (userId: string) => {
|
||||
const user = users.find(u => u.userId === userId);
|
||||
if (user) {
|
||||
const result = await removeUserFromMandate(selectedMandateId, userId);
|
||||
return result.success;
|
||||
}
|
||||
return false;
|
||||
},
|
||||
}}
|
||||
idField="userId"
|
||||
emptyMessage="Keine Mitglieder gefunden"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Add User Modal */}
|
||||
{showAddModal && (
|
||||
<div className={styles.modalOverlay} onClick={() => setShowAddModal(false)}>
|
||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||||
<div className={styles.modalHeader}>
|
||||
<h2 className={styles.modalTitle}>Benutzer zum Mandanten hinzufügen</h2>
|
||||
<button
|
||||
className={styles.modalClose}
|
||||
onClick={() => setShowAddModal(false)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.modalContent}>
|
||||
{availableUsers.length === 0 ? (
|
||||
<p>Alle Benutzer sind bereits diesem Mandanten zugewiesen.</p>
|
||||
) : roleOptions.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Lade Rollen...</span>
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorForm
|
||||
attributes={addUserFields}
|
||||
mode="create"
|
||||
onSubmit={handleAddUser}
|
||||
onCancel={() => setShowAddModal(false)}
|
||||
submitButtonText="Hinzufügen"
|
||||
cancelButtonText="Abbrechen"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Edit Roles Modal */}
|
||||
{editingUser && (
|
||||
<div className={styles.modalOverlay} onClick={() => setEditingUser(null)}>
|
||||
<div className={styles.modal} onClick={e => e.stopPropagation()}>
|
||||
<div className={styles.modalHeader}>
|
||||
<h2 className={styles.modalTitle}>Rollen bearbeiten: {editingUser.username}</h2>
|
||||
<button
|
||||
className={styles.modalClose}
|
||||
onClick={() => setEditingUser(null)}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className={styles.modalContent}>
|
||||
<FormGeneratorForm
|
||||
attributes={editRolesFields}
|
||||
data={{ roleIds: editingUser.roleIds }}
|
||||
mode="edit"
|
||||
onSubmit={handleEditRoles}
|
||||
onCancel={() => setEditingUser(null)}
|
||||
submitButtonText="Speichern"
|
||||
cancelButtonText="Abbrechen"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default AdminUserMandatesPage;
|
||||
|
|
@ -46,22 +46,10 @@ export const AdminUsersPage: React.FC = () => {
|
|||
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [editingUser, setEditingUser] = useState<User | null>(null);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// Generate columns from attributes
|
||||
const columns = useMemo(() => {
|
||||
if (!attributes || attributes.length === 0) {
|
||||
// Default columns if no attributes loaded
|
||||
return [
|
||||
{ key: 'username', label: 'Benutzername', type: 'string' as const, sortable: true, filterable: true, searchable: true, width: 150 },
|
||||
{ key: 'email', label: 'E-Mail', type: 'string' as const, sortable: true, filterable: true, searchable: true, width: 200 },
|
||||
{ key: 'fullName', label: 'Voller Name', type: 'string' as const, sortable: true, filterable: true, searchable: true, width: 180 },
|
||||
{ key: 'enabled', label: 'Aktiv', type: 'boolean' as const, sortable: true, filterable: true, width: 80 },
|
||||
{ key: 'isSysAdmin', label: 'Admin', type: 'boolean' as const, sortable: true, filterable: true, width: 80 },
|
||||
];
|
||||
}
|
||||
|
||||
return attributes.map(attr => ({
|
||||
return (attributes || []).map(attr => ({
|
||||
key: attr.name,
|
||||
label: attr.label || attr.name,
|
||||
type: attr.type as any,
|
||||
|
|
@ -71,6 +59,8 @@ export const AdminUsersPage: React.FC = () => {
|
|||
width: attr.width || 150,
|
||||
minWidth: attr.minWidth || 100,
|
||||
maxWidth: attr.maxWidth || 400,
|
||||
fkSource: (attr as any).fkSource,
|
||||
fkDisplayField: (attr as any).fkDisplayField,
|
||||
}));
|
||||
}, [attributes]);
|
||||
|
||||
|
|
@ -89,30 +79,20 @@ export const AdminUsersPage: React.FC = () => {
|
|||
|
||||
// Handle create submit
|
||||
const handleCreateSubmit = async (data: Partial<User>) => {
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await createUser(data as Omit<User, 'id'>);
|
||||
if (result.success) {
|
||||
setShowCreateModal(false);
|
||||
refetch(); // Refresh the list
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
const result = await createUser(data as Omit<User, 'id'>);
|
||||
if (result.success) {
|
||||
setShowCreateModal(false);
|
||||
refetch(); // Refresh the list
|
||||
}
|
||||
};
|
||||
|
||||
// Handle edit submit
|
||||
const handleEditSubmit = async (data: Partial<User>) => {
|
||||
if (!editingUser) return;
|
||||
setIsSubmitting(true);
|
||||
try {
|
||||
const result = await updateUser(editingUser.id, data);
|
||||
if (result.success) {
|
||||
setEditingUser(null);
|
||||
refetch(); // Refresh the list
|
||||
}
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
const result = await updateUser(editingUser.id, data);
|
||||
if (result.success) {
|
||||
setEditingUser(null);
|
||||
refetch(); // Refresh the list
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -131,31 +111,17 @@ export const AdminUsersPage: React.FC = () => {
|
|||
await handleSendPasswordLink(user.id);
|
||||
};
|
||||
|
||||
// Create form fields (using AttributeDefinition format)
|
||||
const createFields = useMemo(() => [
|
||||
{ name: 'username', label: 'Benutzername', type: 'string' as const, required: true },
|
||||
{ name: 'email', label: 'E-Mail', type: 'email' as const, required: true },
|
||||
{ name: 'fullName', label: 'Voller Name', type: 'string' as const, required: true },
|
||||
{ name: 'language', label: 'Sprache', type: 'enum' as const, options: [
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
{ value: 'en', label: 'English' },
|
||||
]},
|
||||
{ name: 'enabled', label: 'Aktiv', type: 'boolean' as const },
|
||||
{ name: 'isSysAdmin', label: 'System-Administrator', type: 'boolean' as const },
|
||||
], []);
|
||||
|
||||
// Edit form fields (using AttributeDefinition format)
|
||||
const editFields = useMemo(() => [
|
||||
{ name: 'username', label: 'Benutzername', type: 'string' as const, required: true, editable: false },
|
||||
{ name: 'email', label: 'E-Mail', type: 'email' as const, required: true },
|
||||
{ name: 'fullName', label: 'Voller Name', type: 'string' as const, required: true },
|
||||
{ name: 'language', label: 'Sprache', type: 'enum' as const, options: [
|
||||
{ value: 'de', label: 'Deutsch' },
|
||||
{ value: 'en', label: 'English' },
|
||||
]},
|
||||
{ name: 'enabled', label: 'Aktiv', type: 'boolean' as const },
|
||||
{ name: 'isSysAdmin', label: 'System-Administrator', type: 'boolean' as const },
|
||||
], []);
|
||||
// Form attributes from backend - filter for create/edit forms
|
||||
const formAttributes = useMemo(() => {
|
||||
const excludedFields = ['id', 'hashedPassword', 'authenticationAuthority'];
|
||||
return (attributes || [])
|
||||
.filter(attr => !excludedFields.includes(attr.name))
|
||||
.map(attr => ({
|
||||
...attr,
|
||||
// Mark username as readonly for edit mode (will be handled by FormGeneratorForm)
|
||||
editable: attr.name === 'username' ? false : attr.editable,
|
||||
}));
|
||||
}, [attributes]);
|
||||
|
||||
if (error) {
|
||||
return (
|
||||
|
|
@ -278,14 +244,21 @@ export const AdminUsersPage: React.FC = () => {
|
|||
</button>
|
||||
</div>
|
||||
<div className={styles.modalContent}>
|
||||
<FormGeneratorForm
|
||||
attributes={createFields}
|
||||
mode="create"
|
||||
onSubmit={handleCreateSubmit}
|
||||
onCancel={() => setShowCreateModal(false)}
|
||||
submitButtonText="Erstellen"
|
||||
cancelButtonText="Abbrechen"
|
||||
/>
|
||||
{formAttributes.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Lade Formular...</span>
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorForm
|
||||
attributes={formAttributes}
|
||||
mode="create"
|
||||
onSubmit={handleCreateSubmit}
|
||||
onCancel={() => setShowCreateModal(false)}
|
||||
submitButtonText="Erstellen"
|
||||
cancelButtonText="Abbrechen"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
@ -305,15 +278,22 @@ export const AdminUsersPage: React.FC = () => {
|
|||
</button>
|
||||
</div>
|
||||
<div className={styles.modalContent}>
|
||||
<FormGeneratorForm
|
||||
attributes={editFields}
|
||||
data={editingUser}
|
||||
mode="edit"
|
||||
onSubmit={handleEditSubmit}
|
||||
onCancel={() => setEditingUser(null)}
|
||||
submitButtonText="Speichern"
|
||||
cancelButtonText="Abbrechen"
|
||||
/>
|
||||
{formAttributes.length === 0 ? (
|
||||
<div className={styles.loadingContainer}>
|
||||
<div className={styles.spinner} />
|
||||
<span>Lade Formular...</span>
|
||||
</div>
|
||||
) : (
|
||||
<FormGeneratorForm
|
||||
attributes={formAttributes}
|
||||
data={editingUser}
|
||||
mode="edit"
|
||||
onSubmit={handleEditSubmit}
|
||||
onCancel={() => setEditingUser(null)}
|
||||
submitButtonText="Speichern"
|
||||
cancelButtonText="Abbrechen"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -7,3 +7,7 @@
|
|||
export { AdminMandatesPage } from './AdminMandatesPage';
|
||||
export { AdminUsersPage } from './AdminUsersPage';
|
||||
export { AdminRolesPage } from './AdminRolesPage';
|
||||
export { AdminUserMandatesPage } from './AdminUserMandatesPage';
|
||||
export { AdminFeatureAccessPage } from './AdminFeatureAccessPage';
|
||||
export { AdminInvitationsPage } from './AdminInvitationsPage';
|
||||
export { AdminMandateRolesPage } from './AdminMandateRolesPage';
|
||||
Loading…
Reference in a new issue