saas mandates core done

This commit is contained in:
ValueOn AG 2026-01-21 00:32:52 +01:00
parent 70c84dd897
commit 7f07a55c91
32 changed files with 6992 additions and 201 deletions

View file

@ -21,6 +21,7 @@ import Login from './pages/Login';
import Register from './pages/Register'; import Register from './pages/Register';
import PasswordResetRequest from './pages/PasswordResetRequest'; import PasswordResetRequest from './pages/PasswordResetRequest';
import Reset from './pages/Reset'; import Reset from './pages/Reset';
import { InvitePage } from './pages/InvitePage';
// Providers // Providers
import { AuthProvider } from './providers/auth/AuthProvider'; import { AuthProvider } from './providers/auth/AuthProvider';
@ -35,7 +36,7 @@ import { FeatureLayout } from './layouts/FeatureLayout';
import { DashboardPage } from './pages/Dashboard'; import { DashboardPage } from './pages/Dashboard';
import { SettingsPage } from './pages/Settings'; import { SettingsPage } from './pages/Settings';
import { FeatureViewPage } from './pages/FeatureView'; 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() { function App() {
// Load saved theme preference and set app name on app mount // Load saved theme preference and set app name on app mount
@ -72,6 +73,7 @@ function App() {
<Route path="/register" element={<Register />} /> <Route path="/register" element={<Register />} />
<Route path="/password-reset-request" element={<PasswordResetRequest />} /> <Route path="/password-reset-request" element={<PasswordResetRequest />} />
<Route path="/reset" element={<Reset />} /> <Route path="/reset" element={<Reset />} />
<Route path="/invite/:token" element={<InvitePage />} />
{/* ================================================== */} {/* ================================================== */}
{/* PROTECTED ROUTES - REQUIRE AUTHENTICATION */} {/* PROTECTED ROUTES - REQUIRE AUTHENTICATION */}
@ -119,6 +121,10 @@ function App() {
<Route path="mandates" element={<AdminMandatesPage />} /> <Route path="mandates" element={<AdminMandatesPage />} />
<Route path="users" element={<AdminUsersPage />} /> <Route path="users" element={<AdminUsersPage />} />
<Route path="roles" element={<AdminRolesPage />} /> <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>
</Route> </Route>

View 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;

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

View 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;

View file

@ -0,0 +1,8 @@
/**
* AccessRules Components
*
* Components for editing RBAC access rules.
*/
export { AccessRulesEditor } from './AccessRulesEditor';
export { AccessLevelSelect } from './AccessLevelSelect';

View file

@ -298,6 +298,12 @@
overflow: visible; overflow: visible;
} }
/* FK Loading state - shows truncated ID while loading */
.fkLoading {
color: var(--color-text);
opacity: 0.6;
font-style: italic;
}
.tr { .tr {
transition: background-color 0.2s ease; transition: background-color 0.2s ease;

View file

@ -17,6 +17,10 @@ import {
} from '../../../utils/attributeTypeMapper'; } from '../../../utils/attributeTypeMapper';
import type { AttributeType } from '../../../utils/attributeTypeMapper'; import type { AttributeType } from '../../../utils/attributeTypeMapper';
import { FaFilter } from 'react-icons/fa'; 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 // Helper function to detect TextMultilingual objects
// TextMultilingual has structure: { en: string, ge?: string, fr?: string, it?: string } // 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; formatter?: (value: any, row: any) => React.ReactNode;
filterOptions?: string[]; // For enum/select filters filterOptions?: string[]; // For enum/select filters
cellClassName?: (value: any, row: any) => string; // For custom cell styling 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> { 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 [openFilterColumn, setOpenFilterColumn] = useState<string | null>(null);
const filterDropdownRef = useRef<HTMLDivElement>(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 // Generate a storage key based on column names for localStorage persistence
const storageKey = useMemo(() => { const storageKey = useMemo(() => {
if (detectedColumns.length === 0) return null; if (detectedColumns.length === 0) return null;
@ -445,6 +456,161 @@ export function FormGeneratorTable<T extends Record<string, any>>({
} }
}).current; }).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 // Data is already filtered, sorted, and paginated by the backend
// No client-side processing needed // No client-side processing needed
@ -916,6 +1082,19 @@ export function FormGeneratorTable<T extends Record<string, any>>({
return renderBooleanCell(value, column, row); 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 // 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 // Do this BEFORE checking for custom formatters to ensure IDs/hashes are always copyable
const isId = isIdField(column.key); const isId = isIdField(column.key);

View file

@ -22,7 +22,7 @@ import { useMandates, useFeatureStore } from '../../stores/featureStore';
import { useCurrentUser } from '../../hooks/useUsers'; import { useCurrentUser } from '../../hooks/useUsers';
import { FEATURE_REGISTRY, getLabel } from '../../types/mandate'; import { FEATURE_REGISTRY, getLabel } from '../../types/mandate';
import type { Mandate, MandateFeature, FeatureInstance } 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 { TreeNavigation, type TreeItem, type TreeNodeItem } from './TreeNavigation';
import styles from './MandateNavigation.module.css'; import styles from './MandateNavigation.module.css';
@ -195,24 +195,48 @@ export const MandateNavigation: React.FC = () => {
type: 'section', type: 'section',
title: 'ADMINISTRATION', title: 'ADMINISTRATION',
children: [ children: [
{
id: 'admin-mandates',
label: 'Mandanten',
icon: <FaBuilding />,
path: '/admin/mandates',
},
{ {
id: 'admin-users', id: 'admin-users',
label: 'Benutzer', label: 'Benutzer',
icon: <FaUsers />, icon: <FaUsers />,
path: '/admin/users', path: '/admin/users',
}, },
{
id: 'admin-invitations',
label: 'Einladungen',
icon: <FaEnvelopeOpenText />,
path: '/admin/invitations',
},
{ {
id: 'admin-roles', id: 'admin-roles',
label: 'Globale Rollen', label: 'Globale Rollen',
icon: <FaUserShield />, icon: <FaUserShield />,
path: '/admin/roles', 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',
},
], ],
}); });
} }

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

View 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;

View file

@ -0,0 +1,5 @@
/**
* RBAC Export/Import Components
*/
export { RbacExportImport } from './RbacExportImport';

248
src/hooks/useAccessRules.ts Normal file
View 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;

View 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
View 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;

View 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;

View file

@ -148,7 +148,7 @@ export function useAdminMandates() {
return await fetchMandateByIdApi(request, mandateId); return await fetchMandateByIdApi(request, mandateId);
}, [request]); }, [request]);
// Generate columns from attributes // Generate columns from attributes (including fkSource/fkDisplayField for FK resolution)
const columns = attributes.map(attr => ({ const columns = attributes.map(attr => ({
key: attr.name, key: attr.name,
label: attr.label || attr.name, label: attr.label || attr.name,
@ -159,6 +159,8 @@ export function useAdminMandates() {
width: attr.width || 150, width: attr.width || 150,
minWidth: attr.minWidth || 100, minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400, 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 // Create mandate

View 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;

View file

@ -148,7 +148,7 @@ export function useAdminRoles() {
return await fetchRoleByIdApi(request, roleId); return await fetchRoleByIdApi(request, roleId);
}, [request]); }, [request]);
// Generate columns from attributes // Generate columns from attributes (including fkSource/fkDisplayField for FK resolution)
const columns = attributes.map(attr => ({ const columns = attributes.map(attr => ({
key: attr.name, key: attr.name,
label: attr.label || attr.name, label: attr.label || attr.name,
@ -159,6 +159,8 @@ export function useAdminRoles() {
width: attr.width || 150, width: attr.width || 150,
minWidth: attr.minWidth || 100, minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400, 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 // Create role

View 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;

View 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
View 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;

View file

@ -265,3 +265,175 @@
:global(.dark-theme) .infoValue { :global(.dark-theme) .infoValue {
color: var(--text-primary-dark, #ffffff); 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);
}

View file

@ -4,20 +4,105 @@
* Benutzer-Einstellungen (System-Level, ohne Instanz-Kontext). * 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 { 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'; 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}>&times;</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 // SETTINGS PAGE
// ============================================================================= // =============================================================================
export const SettingsPage: React.FC = () => { export const SettingsPage: React.FC = () => {
const { currentLanguage, setLanguage } = useLanguage(); const { currentLanguage, setLanguage } = useLanguage();
const { user: currentUser, refetch: refetchUser } = useCurrentUser();
const { updateUser } = useUser();
const [theme, setTheme] = useState<'light' | 'dark'>( const [theme, setTheme] = useState<'light' | 'dark'>(
() => (localStorage.getItem('theme') as 'light' | 'dark') || 'light' () => (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') => { const handleThemeChange = (newTheme: 'light' | 'dark') => {
setTheme(newTheme); setTheme(newTheme);
localStorage.setItem('theme', newTheme); localStorage.setItem('theme', newTheme);
@ -32,6 +117,87 @@ export const SettingsPage: React.FC = () => {
document.documentElement.setAttribute('data-theme', newTheme); 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 ( return (
<div className={styles.settings}> <div className={styles.settings}>
<header className={styles.header}> <header className={styles.header}>
@ -57,13 +223,13 @@ export const SettingsPage: React.FC = () => {
className={`${styles.themeButton} ${theme === 'light' ? styles.active : ''}`} className={`${styles.themeButton} ${theme === 'light' ? styles.active : ''}`}
onClick={() => handleThemeChange('light')} onClick={() => handleThemeChange('light')}
> >
Hell Hell
</button> </button>
<button <button
className={`${styles.themeButton} ${theme === 'dark' ? styles.active : ''}`} className={`${styles.themeButton} ${theme === 'dark' ? styles.active : ''}`}
onClick={() => handleThemeChange('dark')} onClick={() => handleThemeChange('dark')}
> >
🌙 Dunkel Dunkel
</button> </button>
</div> </div>
</div> </div>
@ -74,18 +240,21 @@ export const SettingsPage: React.FC = () => {
<label className={styles.settingLabel}>Sprache</label> <label className={styles.settingLabel}>Sprache</label>
<p className={styles.settingDescription}> <p className={styles.settingDescription}>
Wähle die Anzeigesprache der Anwendung. Wähle die Anzeigesprache der Anwendung.
{languageError && <span className={styles.errorText}> {languageError}</span>}
</p> </p>
</div> </div>
<div className={styles.settingControl}> <div className={styles.settingControl}>
<select <select
className={styles.select} className={styles.select}
value={currentLanguage} 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="de">Deutsch</option>
<option value="en">English</option> <option value="en">English</option>
<option value="fr">Français</option> <option value="fr">Français</option>
</select> </select>
{isSavingLanguage && <span className={styles.savingIndicator}>Speichern...</span>}
</div> </div>
</div> </div>
</section> </section>
@ -98,29 +267,36 @@ export const SettingsPage: React.FC = () => {
<div className={styles.settingInfo}> <div className={styles.settingInfo}>
<label className={styles.settingLabel}>Profil bearbeiten</label> <label className={styles.settingLabel}>Profil bearbeiten</label>
<p className={styles.settingDescription}> <p className={styles.settingDescription}>
Ändere deinen Namen, E-Mail-Adresse und Profilbild. Ändere deinen Namen und E-Mail-Adresse.
</p> </p>
</div> </div>
<div className={styles.settingControl}> <div className={styles.settingControl}>
<button className={styles.button}> <button
className={styles.button}
onClick={() => setIsProfileModalOpen(true)}
>
Profil öffnen Profil öffnen
</button> </button>
</div> </div>
</div> </div>
<div className={styles.settingRow}> {/* Current user info display */}
<div className={styles.settingInfo}> {currentUser && (
<label className={styles.settingLabel}>Passwort ändern</label> <div className={styles.userInfoCard}>
<p className={styles.settingDescription}> <div className={styles.userInfoRow}>
Aktualisiere dein Passwort für mehr Sicherheit. <span className={styles.userInfoLabel}>Benutzername</span>
</p> <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>
<div className={styles.settingControl}> )}
<button className={styles.button}>
Passwort ändern
</button>
</div>
</div>
</section> </section>
{/* Info */} {/* Info */}
@ -134,11 +310,19 @@ export const SettingsPage: React.FC = () => {
</div> </div>
<div className={styles.infoRow}> <div className={styles.infoRow}>
<span className={styles.infoLabel}>Build</span> <span className={styles.infoLabel}>Build</span>
<span className={styles.infoValue}>2026.01.16</span> <span className={styles.infoValue}>2026.01.20</span>
</div> </div>
</div> </div>
</section> </section>
</main> </main>
{/* Profile Edit Modal */}
<ProfileEditModal
isOpen={isProfileModalOpen}
onClose={() => setIsProfileModalOpen(false)}
userData={currentUser}
onSave={handleProfileSave}
/>
</div> </div>
); );
}; };

View file

@ -86,6 +86,61 @@
border-color: var(--text-secondary); 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 { .tableContainer {
flex: 1; flex: 1;
min-height: 0; min-height: 0;
@ -276,6 +331,75 @@
color: var(--danger-color, #e53e3e); 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 */ /* Dark theme adjustments */
:global(.dark-theme) .modal { :global(.dark-theme) .modal {
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4); box-shadow: 0 4px 20px rgba(0, 0, 0, 0.4);

View 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;

View 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;

View 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;

View file

@ -4,16 +4,17 @@
* Admin page for managing Mandates (tenants) using FormGeneratorTable. * 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 { useAdminMandates, type Mandate } from '../../hooks/useMandates';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; 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 { FaPlus, FaSync, FaBuilding } from 'react-icons/fa';
import styles from './Admin.module.css'; import styles from './Admin.module.css';
export const AdminMandatesPage: React.FC = () => { export const AdminMandatesPage: React.FC = () => {
const { const {
mandates, mandates,
attributes,
columns, columns,
permissions, permissions,
pagination, pagination,
@ -28,9 +29,19 @@ export const AdminMandatesPage: React.FC = () => {
updateOptimistically, updateOptimistically,
} = useAdminMandates(); } = 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 [showCreateModal, setShowCreateModal] = useState(false);
const [editingMandate, setEditingMandate] = useState<Mandate | null>(null); const [editingMandate, setEditingMandate] = useState<Mandate | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Check if user can create // Check if user can create
const canCreate = permissions?.create !== 'n'; const canCreate = permissions?.create !== 'n';
@ -47,28 +58,18 @@ export const AdminMandatesPage: React.FC = () => {
// Handle create submit // Handle create submit
const handleCreateSubmit = async (data: Partial<Mandate>) => { const handleCreateSubmit = async (data: Partial<Mandate>) => {
setIsSubmitting(true); const success = await handleCreate(data);
try { if (success) {
const success = await handleCreate(data); setShowCreateModal(false);
if (success) {
setShowCreateModal(false);
}
} finally {
setIsSubmitting(false);
} }
}; };
// Handle edit submit // Handle edit submit
const handleEditSubmit = async (data: Partial<Mandate>) => { const handleEditSubmit = async (data: Partial<Mandate>) => {
if (!editingMandate) return; if (!editingMandate) return;
setIsSubmitting(true); const success = await handleUpdate(editingMandate.id, data);
try { if (success) {
const success = await handleUpdate(editingMandate.id, data); setEditingMandate(null);
if (success) {
setEditingMandate(null);
}
} finally {
setIsSubmitting(false);
} }
}; };
@ -191,18 +192,21 @@ export const AdminMandatesPage: React.FC = () => {
</button> </button>
</div> </div>
<div className={styles.modalContent}> <div className={styles.modalContent}>
<FormGeneratorForm {formAttributes.length === 0 ? (
fields={[ <div className={styles.loadingContainer}>
{ key: 'name', label: 'Name', type: 'string', required: true }, <div className={styles.spinner} />
{ key: 'description', label: 'Beschreibung', type: 'textarea' }, <span>Lade Formular...</span>
{ key: 'enabled', label: 'Aktiv', type: 'boolean' }, </div>
]} ) : (
onSubmit={handleCreateSubmit} <FormGeneratorForm
onCancel={() => setShowCreateModal(false)} attributes={formAttributes}
submitLabel="Erstellen" mode="create"
cancelLabel="Abbrechen" onSubmit={handleCreateSubmit}
loading={isSubmitting} onCancel={() => setShowCreateModal(false)}
/> submitButtonText="Erstellen"
cancelButtonText="Abbrechen"
/>
)}
</div> </div>
</div> </div>
</div> </div>
@ -222,19 +226,22 @@ export const AdminMandatesPage: React.FC = () => {
</button> </button>
</div> </div>
<div className={styles.modalContent}> <div className={styles.modalContent}>
<FormGeneratorForm {formAttributes.length === 0 ? (
fields={[ <div className={styles.loadingContainer}>
{ key: 'name', label: 'Name', type: 'string', required: true }, <div className={styles.spinner} />
{ key: 'description', label: 'Beschreibung', type: 'textarea' }, <span>Lade Formular...</span>
{ key: 'enabled', label: 'Aktiv', type: 'boolean' }, </div>
]} ) : (
initialData={editingMandate} <FormGeneratorForm
onSubmit={handleEditSubmit} attributes={formAttributes}
onCancel={() => setEditingMandate(null)} data={editingMandate}
submitLabel="Speichern" mode="edit"
cancelLabel="Abbrechen" onSubmit={handleEditSubmit}
loading={isSubmitting} onCancel={() => setEditingMandate(null)}
/> submitButtonText="Speichern"
cancelButtonText="Abbrechen"
/>
)}
</div> </div>
</div> </div>
</div> </div>

View file

@ -7,13 +7,14 @@
import React, { useState, useMemo } from 'react'; import React, { useState, useMemo } from 'react';
import { useAdminRoles, type Role } from '../../hooks/useRoles'; import { useAdminRoles, type Role } from '../../hooks/useRoles';
import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; 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 { FaPlus, FaSync, FaUserShield } from 'react-icons/fa';
import styles from './Admin.module.css'; import styles from './Admin.module.css';
export const AdminRolesPage: React.FC = () => { export const AdminRolesPage: React.FC = () => {
const { const {
roles, roles,
attributes,
columns, columns,
permissions, permissions,
pagination, pagination,
@ -32,15 +33,11 @@ export const AdminRolesPage: React.FC = () => {
const [editingRole, setEditingRole] = useState<Role | null>(null); const [editingRole, setEditingRole] = useState<Role | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false); 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(() => { const displayColumns = useMemo(() => {
if (columns.length > 0) return columns; const excludedColumns = ['mandateId', 'featureInstanceId', 'featureCode'];
return columns.filter(col => !excludedColumns.includes(col.key));
// 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 },
];
}, [columns]); }, [columns]);
// Check permissions // Check permissions
@ -60,15 +57,7 @@ export const AdminRolesPage: React.FC = () => {
const handleCreateSubmit = async (data: Partial<Role>) => { const handleCreateSubmit = async (data: Partial<Role>) => {
setIsSubmitting(true); setIsSubmitting(true);
try { try {
// Transform description to TextMultilingual format if it's a string const success = await handleCreate(data);
const roleData: Partial<Role> = {
...data,
description: typeof data.description === 'string'
? { en: data.description }
: data.description,
};
const success = await handleCreate(roleData);
if (success) { if (success) {
setShowCreateModal(false); setShowCreateModal(false);
} }
@ -82,15 +71,7 @@ export const AdminRolesPage: React.FC = () => {
if (!editingRole) return; if (!editingRole) return;
setIsSubmitting(true); setIsSubmitting(true);
try { try {
// Transform description to TextMultilingual format if it's a string const success = await handleUpdate(editingRole.id, data);
const roleData: Partial<Role> = {
...data,
description: typeof data.description === 'string'
? { en: data.description }
: data.description,
};
const success = await handleUpdate(editingRole.id, roleData);
if (success) { if (success) {
setEditingRole(null); setEditingRole(null);
} }
@ -106,11 +87,18 @@ export const AdminRolesPage: React.FC = () => {
} }
}; };
// Form fields for create/edit // Form attributes from backend - filter for create/edit forms
const formFields = useMemo(() => [ // Exclude fields not relevant for global roles (mandateId, featureInstanceId, etc.)
{ key: 'roleLabel', label: 'Rollen-Label', type: 'string' as const, required: true }, const formAttributes: AttributeDefinition[] = useMemo(() => {
{ key: 'description', label: 'Beschreibung', type: 'textarea' as const }, 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) { if (error) {
return ( return (
@ -224,14 +212,21 @@ export const AdminRolesPage: React.FC = () => {
</button> </button>
</div> </div>
<div className={styles.modalContent}> <div className={styles.modalContent}>
<FormGeneratorForm {formAttributes.length === 0 ? (
fields={formFields} <div className={styles.loadingContainer}>
onSubmit={handleCreateSubmit} <div className={styles.spinner} />
onCancel={() => setShowCreateModal(false)} <span>Lade Formular...</span>
submitLabel="Erstellen" </div>
cancelLabel="Abbrechen" ) : (
loading={isSubmitting} <FormGeneratorForm
/> attributes={formAttributes}
mode="create"
onSubmit={handleCreateSubmit}
onCancel={() => setShowCreateModal(false)}
submitButtonText={isSubmitting ? 'Erstelle...' : 'Erstellen'}
cancelButtonText="Abbrechen"
/>
)}
</div> </div>
</div> </div>
</div> </div>
@ -251,21 +246,22 @@ export const AdminRolesPage: React.FC = () => {
</button> </button>
</div> </div>
<div className={styles.modalContent}> <div className={styles.modalContent}>
<FormGeneratorForm {formAttributes.length === 0 ? (
fields={formFields} <div className={styles.loadingContainer}>
initialData={{ <div className={styles.spinner} />
...editingRole, <span>Lade Formular...</span>
// Extract description from TextMultilingual if needed </div>
description: typeof editingRole.description === 'object' ) : (
? editingRole.description?.en || editingRole.description?.ge || '' <FormGeneratorForm
: editingRole.description, attributes={formAttributes}
}} data={editingRole}
onSubmit={handleEditSubmit} mode="edit"
onCancel={() => setEditingRole(null)} onSubmit={handleEditSubmit}
submitLabel="Speichern" onCancel={() => setEditingRole(null)}
cancelLabel="Abbrechen" submitButtonText={isSubmitting ? 'Speichern...' : 'Speichern'}
loading={isSubmitting} cancelButtonText="Abbrechen"
/> />
)}
</div> </div>
</div> </div>
</div> </div>

View 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;

View file

@ -46,22 +46,10 @@ export const AdminUsersPage: React.FC = () => {
const [showCreateModal, setShowCreateModal] = useState(false); const [showCreateModal, setShowCreateModal] = useState(false);
const [editingUser, setEditingUser] = useState<User | null>(null); const [editingUser, setEditingUser] = useState<User | null>(null);
const [isSubmitting, setIsSubmitting] = useState(false);
// Generate columns from attributes // Generate columns from attributes
const columns = useMemo(() => { const columns = useMemo(() => {
if (!attributes || attributes.length === 0) { return (attributes || []).map(attr => ({
// 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 => ({
key: attr.name, key: attr.name,
label: attr.label || attr.name, label: attr.label || attr.name,
type: attr.type as any, type: attr.type as any,
@ -71,6 +59,8 @@ export const AdminUsersPage: React.FC = () => {
width: attr.width || 150, width: attr.width || 150,
minWidth: attr.minWidth || 100, minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400, maxWidth: attr.maxWidth || 400,
fkSource: (attr as any).fkSource,
fkDisplayField: (attr as any).fkDisplayField,
})); }));
}, [attributes]); }, [attributes]);
@ -89,30 +79,20 @@ export const AdminUsersPage: React.FC = () => {
// Handle create submit // Handle create submit
const handleCreateSubmit = async (data: Partial<User>) => { const handleCreateSubmit = async (data: Partial<User>) => {
setIsSubmitting(true); const result = await createUser(data as Omit<User, 'id'>);
try { if (result.success) {
const result = await createUser(data as Omit<User, 'id'>); setShowCreateModal(false);
if (result.success) { refetch(); // Refresh the list
setShowCreateModal(false);
refetch(); // Refresh the list
}
} finally {
setIsSubmitting(false);
} }
}; };
// Handle edit submit // Handle edit submit
const handleEditSubmit = async (data: Partial<User>) => { const handleEditSubmit = async (data: Partial<User>) => {
if (!editingUser) return; if (!editingUser) return;
setIsSubmitting(true); const result = await updateUser(editingUser.id, data);
try { if (result.success) {
const result = await updateUser(editingUser.id, data); setEditingUser(null);
if (result.success) { refetch(); // Refresh the list
setEditingUser(null);
refetch(); // Refresh the list
}
} finally {
setIsSubmitting(false);
} }
}; };
@ -131,31 +111,17 @@ export const AdminUsersPage: React.FC = () => {
await handleSendPasswordLink(user.id); await handleSendPasswordLink(user.id);
}; };
// Create form fields (using AttributeDefinition format) // Form attributes from backend - filter for create/edit forms
const createFields = useMemo(() => [ const formAttributes = useMemo(() => {
{ name: 'username', label: 'Benutzername', type: 'string' as const, required: true }, const excludedFields = ['id', 'hashedPassword', 'authenticationAuthority'];
{ name: 'email', label: 'E-Mail', type: 'email' as const, required: true }, return (attributes || [])
{ name: 'fullName', label: 'Voller Name', type: 'string' as const, required: true }, .filter(attr => !excludedFields.includes(attr.name))
{ name: 'language', label: 'Sprache', type: 'enum' as const, options: [ .map(attr => ({
{ value: 'de', label: 'Deutsch' }, ...attr,
{ value: 'en', label: 'English' }, // Mark username as readonly for edit mode (will be handled by FormGeneratorForm)
]}, editable: attr.name === 'username' ? false : attr.editable,
{ name: 'enabled', label: 'Aktiv', type: 'boolean' as const }, }));
{ name: 'isSysAdmin', label: 'System-Administrator', type: 'boolean' as const }, }, [attributes]);
], []);
// 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 },
], []);
if (error) { if (error) {
return ( return (
@ -278,14 +244,21 @@ export const AdminUsersPage: React.FC = () => {
</button> </button>
</div> </div>
<div className={styles.modalContent}> <div className={styles.modalContent}>
<FormGeneratorForm {formAttributes.length === 0 ? (
attributes={createFields} <div className={styles.loadingContainer}>
mode="create" <div className={styles.spinner} />
onSubmit={handleCreateSubmit} <span>Lade Formular...</span>
onCancel={() => setShowCreateModal(false)} </div>
submitButtonText="Erstellen" ) : (
cancelButtonText="Abbrechen" <FormGeneratorForm
/> attributes={formAttributes}
mode="create"
onSubmit={handleCreateSubmit}
onCancel={() => setShowCreateModal(false)}
submitButtonText="Erstellen"
cancelButtonText="Abbrechen"
/>
)}
</div> </div>
</div> </div>
</div> </div>
@ -305,15 +278,22 @@ export const AdminUsersPage: React.FC = () => {
</button> </button>
</div> </div>
<div className={styles.modalContent}> <div className={styles.modalContent}>
<FormGeneratorForm {formAttributes.length === 0 ? (
attributes={editFields} <div className={styles.loadingContainer}>
data={editingUser} <div className={styles.spinner} />
mode="edit" <span>Lade Formular...</span>
onSubmit={handleEditSubmit} </div>
onCancel={() => setEditingUser(null)} ) : (
submitButtonText="Speichern" <FormGeneratorForm
cancelButtonText="Abbrechen" attributes={formAttributes}
/> data={editingUser}
mode="edit"
onSubmit={handleEditSubmit}
onCancel={() => setEditingUser(null)}
submitButtonText="Speichern"
cancelButtonText="Abbrechen"
/>
)}
</div> </div>
</div> </div>
</div> </div>

View file

@ -7,3 +7,7 @@
export { AdminMandatesPage } from './AdminMandatesPage'; export { AdminMandatesPage } from './AdminMandatesPage';
export { AdminUsersPage } from './AdminUsersPage'; export { AdminUsersPage } from './AdminUsersPage';
export { AdminRolesPage } from './AdminRolesPage'; export { AdminRolesPage } from './AdminRolesPage';
export { AdminUserMandatesPage } from './AdminUserMandatesPage';
export { AdminFeatureAccessPage } from './AdminFeatureAccessPage';
export { AdminInvitationsPage } from './AdminInvitationsPage';
export { AdminMandateRolesPage } from './AdminMandateRolesPage';