gpdr compliancy implemented

This commit is contained in:
ValueOn AG 2026-01-25 23:57:47 +01:00
parent bf4ddc6fd5
commit 2b220fe816
24 changed files with 2541 additions and 237 deletions

View file

@ -6,6 +6,7 @@
* URL-Struktur:
* - / Dashboard/Übersicht
* - /settings Benutzer-Einstellungen
* - /gdpr → GDPR / Datenschutz
* - /mandates/:mandateId/:featureCode/:instanceId/* Feature-Instanz-Routen
* - /admin/* System-Administration (nur SysAdmin)
*/
@ -38,8 +39,9 @@ import { FeatureLayout } from './layouts/FeatureLayout';
// Pages
import { DashboardPage } from './pages/Dashboard';
import { SettingsPage } from './pages/Settings';
import { GDPRPage } from './pages/GDPR';
import { FeatureViewPage } from './pages/FeatureView';
import { AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage } from './pages/admin';
import { AdminMandatesPage, AdminUsersPage, AdminUserMandatesPage, AdminFeatureAccessPage, AdminInvitationsPage, AdminMandateRolesPage, AdminFeatureRolesPage, AdminFeatureInstanceUsersPage, AdminMandateRolePermissionsPage, AdminUserAccessOverviewPage } from './pages/admin';
// Workflow Pages (global)
import { PlaygroundPage, WorkflowsPage, AutomationsPage } from './pages/workflows';
@ -103,6 +105,7 @@ function App() {
{/* System-Seiten (ohne Instanz-Kontext) */}
<Route path="settings" element={<SettingsPage />} />
<Route path="gdpr" element={<GDPRPage />} />
{/* ============================================== */}
{/* WORKFLOWS ROUTES (global) */}
@ -150,6 +153,7 @@ function App() {
<Route path="files" element={<FeatureViewPage view="files" />} />
<Route path="conversations" element={<FeatureViewPage view="conversations" />} />
<Route path="position-documents" element={<FeatureViewPage view="position-documents" />} />
<Route path="expense-import" element={<FeatureViewPage view="expense-import" />} />
<Route path="instance-roles" element={<FeatureViewPage view="instance-roles" />} />
{/* Catch-all für unbekannte Sub-Pfade */}
@ -169,6 +173,7 @@ function App() {
<Route path="invitations" element={<AdminInvitationsPage />} />
<Route path="mandate-roles" element={<AdminMandateRolesPage />} />
<Route path="mandate-role-permissions" element={<AdminMandateRolePermissionsPage />} />
<Route path="user-access-overview" element={<AdminUserAccessOverviewPage />} />
</Route>
</Route>

View file

@ -126,11 +126,19 @@ api.interceptors.response.use(
error.config?.url?.includes('/api/local/login') ||
error.config?.url?.includes('/api/msft/login');
// Don't redirect if we're already on the login page (prevents redirect loops)
const isOnLoginPage = window.location.pathname === '/login' ||
window.location.pathname.startsWith('/login');
// Don't redirect if we're on a public auth page (prevents redirect loops and allows public pages to work)
const pathname = window.location.pathname;
const isOnPublicAuthPage = pathname === '/login' ||
pathname.startsWith('/login') ||
pathname === '/register' ||
pathname.startsWith('/register') ||
pathname === '/reset' ||
pathname.startsWith('/reset') ||
pathname === '/password-reset-request' ||
pathname.startsWith('/password-reset-request') ||
pathname.startsWith('/invite');
if (!isLoginEndpoint && !isOnLoginPage) {
if (!isLoginEndpoint && !isOnPublicAuthPage) {
// Clear local auth data (httpOnly cookies are cleared by backend)
sessionStorage.removeItem('auth_authority');
clearUserDataCache();

View file

@ -186,9 +186,13 @@ export function FormGeneratorForm<T extends Record<string, any>>({
filtered = filterFields(filtered);
} else {
// Default filtering based on mode
// Note: readonly fields (editable === false) should be shown but rendered as read-only
// Only hide fields where visible === false explicitly
if (mode === 'edit') {
filtered = filtered.filter(attr => attr.visible !== false && attr.editable !== false);
// Show all visible fields (readonly fields are rendered as non-editable in renderField)
filtered = filtered.filter(attr => attr.visible !== false);
} else if (mode === 'create') {
// In create mode, hide truly non-editable fields (user can't set them)
filtered = filtered.filter(attr => attr.visible !== false && attr.editable !== false);
} else if (mode === 'display') {
filtered = filtered.filter(attr => attr.visible !== false);
@ -718,11 +722,17 @@ export function FormGeneratorForm<T extends Record<string, any>>({
const option = options.find(opt => String(opt.value) === String(v));
return option ? option.label : v;
}).join(', ') || t('common.none', 'None');
} else if (typeof value === 'object' && value !== null) {
// Convert objects/arrays to formatted JSON string for display
displayValue = JSON.stringify(value, null, 2);
}
// Use pre tag for JSON/object values to preserve formatting
const isJsonValue = typeof value === 'object' && value !== null;
return (
<div className={styles.floatingLabelInput} key={attr.name}>
<div className={styles.readonlyField}>
<div className={styles.readonlyField} style={isJsonValue ? { whiteSpace: 'pre-wrap', fontFamily: 'monospace', fontSize: '0.85em' } : undefined}>
{displayValue || t('common.na', 'N/A')}
</div>
<label className={styles.focusedLabel}>

View file

@ -35,6 +35,7 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
// System pages
'page.system.home': <FaHome />,
'page.system.settings': <FaCog />,
'page.system.gdpr': <FaShieldAlt />,
'page.system.playground': <FaPlay />,
'page.system.chats': <FaListAlt />,
'page.system.automations': <FaCogs />,
@ -55,12 +56,14 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
'page.admin.feature-roles': <FaCube />,
'page.admin.feature-instances': <FaCubes />,
'page.admin.feature-users': <FaUsersCog />,
'page.admin.user-access-overview': <FaUserShield />,
// Feature pages - Trustee
'page.feature.trustee.dashboard': <FaChartLine />,
'page.feature.trustee.positions': <FaDatabase />,
'page.feature.trustee.documents': <FaFileAlt />,
'page.feature.trustee.position-documents': <FaLink />,
'page.feature.trustee.expense-import': <FaFileAlt />,
'page.feature.trustee.instance-roles': <FaUserShield />,
// Feature pages - Real Estate

View file

@ -7,9 +7,9 @@ import { useAutomations, useAutomationOperations } from '../../../../hooks/useAu
const attributesToColumns = (attributes: any[]) => {
return attributes
.filter(attr => {
// Exclude template and complex fields from table display
// Exclude template, placeholders and complex fields from table display
const attrNameLower = attr.name.toLowerCase();
const excludedColumns = ['template', 'executionlogs', 'execution_logs'];
const excludedColumns = ['template', 'placeholders', 'executionlogs', 'execution_logs'];
return !excludedColumns.includes(attrNameLower);
})
.map(attr => {

View file

@ -62,14 +62,6 @@ export interface InvitationValidation {
roleLabels?: string[];
}
export interface RegisterAndAcceptData {
token: string;
username: string;
email: string;
password: string;
firstname?: string;
lastname?: string;
}
/**
* Hook for managing invitations
@ -246,26 +238,6 @@ export function useInvitations() {
}
}, []);
/**
* 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,
@ -276,7 +248,6 @@ export function useInvitations() {
revokeInvitation,
validateInvitation,
acceptInvitation,
registerAndAccept,
};
}

View file

@ -17,6 +17,7 @@ import { TrusteePositionsView } from './views/trustee/TrusteePositionsView';
import { TrusteePositionDocumentsView } from './views/trustee/TrusteePositionDocumentsView';
import { TrusteeDashboardView } from './views/trustee/TrusteeDashboardView';
import { TrusteeInstanceRolesView } from './views/trustee/TrusteeInstanceRolesView';
import { TrusteeExpenseImportView } from './views/trustee/TrusteeExpenseImportView';
import styles from './FeatureView.module.css';
@ -81,6 +82,7 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
positions: TrusteePositionsView,
'position-documents': TrusteePositionDocumentsView,
'instance-roles': TrusteeInstanceRolesView,
'expense-import': TrusteeExpenseImportView,
},
chatworkflow: {
dashboard: ChatworkflowDashboard,

251
src/pages/GDPR.module.css Normal file
View file

@ -0,0 +1,251 @@
/**
* GDPR Page Styles
*/
.gdpr {
padding: 2rem;
max-width: 1000px;
margin: 0 auto;
}
.header {
display: flex;
justify-content: space-between;
gap: 1rem;
align-items: flex-start;
margin-bottom: 2rem;
}
.title {
display: flex;
align-items: center;
gap: 0.75rem;
margin: 0;
font-size: 1.75rem;
font-weight: 700;
color: var(--text-primary, #1a1a1a);
}
.titleIcon {
color: var(--primary-color, #2563eb);
}
.subtitle {
margin: 0.5rem 0 0;
color: var(--text-secondary, #666);
font-size: 0.9375rem;
}
.backLink {
text-decoration: none;
color: var(--primary-color, #2563eb);
font-size: 0.875rem;
font-weight: 500;
}
.content {
display: flex;
flex-direction: column;
gap: 2rem;
}
.section {
background: var(--surface-color, #ffffff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 12px;
padding: 1.5rem;
}
.sectionTitle {
margin: 0 0 1.25rem;
font-size: 1rem;
font-weight: 600;
color: var(--text-primary, #1a1a1a);
}
.actions {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1rem;
}
.actionCard {
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 10px;
padding: 1rem;
background: var(--bg-primary, #ffffff);
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.actionCard h3 {
margin: 0;
font-size: 0.95rem;
color: var(--text-primary, #1a1a1a);
}
.actionCard p {
margin: 0;
font-size: 0.8125rem;
color: var(--text-secondary, #666);
}
.deleteConfirm {
display: flex;
flex-direction: column;
gap: 0.75rem;
border-top: 1px solid var(--border-color, #e0e0e0);
padding-top: 0.75rem;
}
.deleteWarning {
margin: 0;
font-size: 0.8125rem;
color: var(--text-secondary, #666);
}
.deleteInput {
padding: 0.5rem 0.75rem;
border-radius: 6px;
border: 1px solid var(--border-color, #d0d0d0);
font-size: 0.875rem;
}
.deleteActions {
display: flex;
gap: 0.5rem;
justify-content: flex-end;
flex-wrap: wrap;
}
.primaryButton,
.secondaryButton,
.dangerButton {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.5rem 0.9rem;
border-radius: 6px;
font-size: 0.875rem;
font-weight: 600;
cursor: pointer;
border: 1px solid transparent;
transition: all 0.2s ease;
}
.primaryButton {
background: var(--primary-color, #2563eb);
color: #fff;
}
.primaryButton:hover {
filter: brightness(0.95);
}
.secondaryButton {
background: var(--surface-color, #f5f5f5);
color: var(--text-primary, #1a1a1a);
border-color: var(--border-color, #d0d0d0);
}
.secondaryButton:hover {
background: var(--surface-color, #ededed);
}
.dangerButton {
background: #dc2626;
color: #fff;
}
.dangerButton:hover {
filter: brightness(0.95);
}
.primaryButton:disabled,
.secondaryButton:disabled,
.dangerButton:disabled {
opacity: 0.7;
cursor: not-allowed;
}
.buttonSpinner {
display: inline-flex;
align-items: center;
gap: 0.5rem;
}
.buttonSpinner svg {
animation: spinnerRotate 1s linear infinite;
}
.message {
margin-top: 1rem;
padding: 0.75rem 1rem;
border-radius: 8px;
font-size: 0.875rem;
}
.successMessage {
background: #ecfdf3;
color: #166534;
border: 1px solid #bbf7d0;
}
.errorMessage {
background: #fef2f2;
color: #991b1b;
border: 1px solid #fecaca;
}
.infoGrid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(240px, 1fr));
gap: 1rem;
}
.infoBlock {
background: var(--surface-color, #f8fafc);
border-radius: 10px;
padding: 1rem;
border: 1px solid var(--border-color, #e0e0e0);
}
.infoBlock h3 {
margin: 0 0 0.75rem;
font-size: 0.9rem;
}
.infoBlock ul {
margin: 0;
padding-left: 1.1rem;
font-size: 0.8125rem;
color: var(--text-secondary, #666);
}
.infoBlock li {
margin-bottom: 0.35rem;
}
.mutedText {
color: var(--text-secondary, #666);
font-size: 0.875rem;
}
.errorText {
color: #991b1b;
font-size: 0.875rem;
}
@media (max-width: 720px) {
.header {
flex-direction: column;
align-items: flex-start;
}
}
@keyframes spinnerRotate {
to {
transform: rotate(360deg);
}
}

334
src/pages/GDPR.tsx Normal file
View file

@ -0,0 +1,334 @@
/**
* GDPR Page
*
* Provides access to user data rights (export, portability, deletion).
*/
import React, { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { FaDownload, FaFileExport, FaShieldAlt, FaSpinner, FaTrash } from 'react-icons/fa';
import api from '../api';
import { clearUserDataCache } from '../utils/userCache';
import styles from './GDPR.module.css';
type ConsentInfo = {
dataCollected?: Record<string, string>;
dataProcessing?: Record<string, string>;
userRights?: Record<string, string>;
contact?: Record<string, string>;
};
type ActionMessage = {
type: 'success' | 'error';
text: string;
};
const downloadJson = (data: unknown, fileName: string, mimeType = 'application/json') => {
const fileBlob = new Blob([JSON.stringify(data, null, 2)], { type: mimeType });
const fileUrl = URL.createObjectURL(fileBlob);
const link = document.createElement('a');
link.href = fileUrl;
link.download = fileName;
document.body.appendChild(link);
link.click();
link.remove();
URL.revokeObjectURL(fileUrl);
};
export const GDPRPage: React.FC = () => {
const contactEmail = 'p.motsch@poweron.swiss';
const [consentInfo, setConsentInfo] = useState<ConsentInfo | null>(null);
const [isLoadingConsent, setIsLoadingConsent] = useState(true);
const [consentError, setConsentError] = useState<string | null>(null);
const [isExporting, setIsExporting] = useState(false);
const [isPortabilityExporting, setIsPortabilityExporting] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
const [actionMessage, setActionMessage] = useState<ActionMessage | null>(null);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [deleteConfirmText, setDeleteConfirmText] = useState('');
const [isDeleted, setIsDeleted] = useState(false);
const isActionLocked = isDeleting || isDeleted;
useEffect(() => {
let isActive = true;
const loadConsentInfo = async () => {
setIsLoadingConsent(true);
setConsentError(null);
try {
const response = await api.get('/api/user/me/consent-info');
if (isActive) {
setConsentInfo(response.data as ConsentInfo);
}
} catch (error: any) {
console.error('Failed to load GDPR consent info:', error);
if (isActive) {
setConsentError('Consent information could not be loaded.');
}
} finally {
if (isActive) {
setIsLoadingConsent(false);
}
}
};
loadConsentInfo();
return () => {
isActive = false;
};
}, []);
const handleDataExport = async () => {
if (isActionLocked) return;
setIsExporting(true);
setActionMessage(null);
try {
const response = await api.get('/api/user/me/data-export');
downloadJson(response.data, 'gdpr-data-export.json');
setActionMessage({ type: 'success', text: 'Data export downloaded.' });
} catch (error: any) {
console.error('GDPR export failed:', error);
setActionMessage({ type: 'error', text: 'Data export failed. Please try again.' });
} finally {
setIsExporting(false);
}
};
const handlePortabilityExport = async () => {
if (isActionLocked) return;
setIsPortabilityExporting(true);
setActionMessage(null);
try {
const response = await api.get('/api/user/me/data-portability', {
headers: { Accept: 'application/ld+json' }
});
downloadJson(response.data, 'gdpr-data-portability.json', 'application/ld+json');
setActionMessage({ type: 'success', text: 'Portable export downloaded.' });
} catch (error: any) {
console.error('GDPR portability export failed:', error);
setActionMessage({ type: 'error', text: 'Portable export failed. Please try again.' });
} finally {
setIsPortabilityExporting(false);
}
};
const handleDeleteAccount = async () => {
setActionMessage(null);
if (deleteConfirmText !== 'LOESCHEN') {
setActionMessage({ type: 'error', text: 'Please type LOESCHEN to confirm deletion.' });
return;
}
setIsDeleting(true);
try {
await api.delete('/api/user/me/', { params: { confirmDeletion: true } });
localStorage.removeItem('authToken');
sessionStorage.removeItem('auth_authority');
clearUserDataCache();
setIsDeleted(true);
setActionMessage({ type: 'success', text: 'Account deleted. Redirecting to login...' });
window.location.replace('/login');
} catch (error: any) {
console.error('GDPR deletion failed:', error);
setActionMessage({ type: 'error', text: 'Account deletion failed. Please try again.' });
} finally {
setIsDeleting(false);
}
};
return (
<div className={styles.gdpr}>
<header className={styles.header}>
<div>
<h1 className={styles.title}>
<FaShieldAlt className={styles.titleIcon} />
GDPR / Privacy
</h1>
<p className={styles.subtitle}>
Manage your personal data exports and account deletion.
</p>
</div>
<Link to="/settings" className={styles.backLink}>
Back to Settings
</Link>
</header>
<main className={styles.content}>
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Your data rights</h2>
<div className={styles.actions}>
<div className={styles.actionCard}>
<h3>Access (Article 15)</h3>
<p>Download a full export of your account data.</p>
<button
className={styles.primaryButton}
onClick={handleDataExport}
disabled={isExporting || isActionLocked}
>
{isExporting ? (
<span className={styles.buttonSpinner}>
<FaSpinner />
Exporting...
</span>
) : (
<>
<FaDownload />
Export data
</>
)}
</button>
</div>
<div className={styles.actionCard}>
<h3>Portability (Article 20)</h3>
<p>Download a machine-readable JSON-LD export.</p>
<button
className={styles.secondaryButton}
onClick={handlePortabilityExport}
disabled={isPortabilityExporting || isActionLocked}
>
{isPortabilityExporting ? (
<span className={styles.buttonSpinner}>
<FaSpinner />
Exporting...
</span>
) : (
<>
<FaFileExport />
Export portable data
</>
)}
</button>
</div>
<div className={styles.actionCard}>
<h3>Erasure (Article 17)</h3>
<p>Permanently delete your account and all associated data.</p>
{!showDeleteConfirm && (
<button
className={styles.dangerButton}
onClick={() => setShowDeleteConfirm(true)}
disabled={isActionLocked}
>
<FaTrash />
Start deletion
</button>
)}
{showDeleteConfirm && (
<div className={styles.deleteConfirm}>
<p className={styles.deleteWarning}>
This action is irreversible. Type <strong>LOESCHEN</strong> to confirm.
</p>
<input
className={styles.deleteInput}
value={deleteConfirmText}
onChange={(event) => setDeleteConfirmText(event.target.value)}
placeholder="LOESCHEN"
disabled={isDeleting}
/>
<div className={styles.deleteActions}>
<button
className={styles.secondaryButton}
onClick={() => {
setShowDeleteConfirm(false);
setDeleteConfirmText('');
}}
disabled={isDeleting}
>
Cancel
</button>
<button
className={styles.dangerButton}
onClick={handleDeleteAccount}
disabled={isDeleting || deleteConfirmText !== 'LOESCHEN'}
>
{isDeleting ? (
<span className={styles.buttonSpinner}>
<FaSpinner />
Deleting...
</span>
) : (
<>
<FaTrash />
Confirm deletion
</>
)}
</button>
</div>
</div>
)}
</div>
</div>
{actionMessage && (
<div
className={`${styles.message} ${
actionMessage.type === 'success' ? styles.successMessage : styles.errorMessage
}`}
>
{actionMessage.text}
</div>
)}
</section>
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Processing information</h2>
{isLoadingConsent && <p className={styles.mutedText}>Loading consent info...</p>}
{consentError && <p className={styles.errorText}>{consentError}</p>}
{!isLoadingConsent && !consentError && consentInfo && (
<div className={styles.infoGrid}>
<div className={styles.infoBlock}>
<h3>Data collected</h3>
<ul>
{Object.entries(consentInfo.dataCollected || {}).map(([key, value]) => (
<li key={key}>
<strong>{key}:</strong> {value}
</li>
))}
</ul>
</div>
<div className={styles.infoBlock}>
<h3>Processing</h3>
<ul>
{Object.entries(consentInfo.dataProcessing || {}).map(([key, value]) => (
<li key={key}>
<strong>{key}:</strong> {value}
</li>
))}
</ul>
</div>
<div className={styles.infoBlock}>
<h3>Your rights</h3>
<ul>
{Object.entries(consentInfo.userRights || {}).map(([key, value]) => (
<li key={key}>
<strong>{key}:</strong> {value}
</li>
))}
</ul>
</div>
<div className={styles.infoBlock}>
<h3>Contact</h3>
<ul>
{Object.entries({
...(consentInfo.contact || {}),
email: contactEmail,
}).map(([key, value]) => (
<li key={key}>
<strong>{key}:</strong> {value}
</li>
))}
</ul>
</div>
</div>
)}
</section>
</main>
</div>
);
};
export default GDPRPage;

View file

@ -299,6 +299,46 @@
margin: 0 0 0.75rem 0;
}
/* Auth Prompt (for unauthenticated users) */
.authPrompt {
text-align: center;
padding: 1rem;
margin-bottom: 1.5rem;
}
.authPrompt p {
color: var(--text-secondary);
margin: 0;
font-size: 0.9rem;
}
.authActions {
display: flex;
flex-direction: column;
gap: 0.75rem;
}
.authInfo {
text-align: center;
padding: 1rem;
margin-top: 1rem;
background: var(--bg-secondary);
border-radius: 8px;
}
.authInfo p {
color: var(--text-secondary);
font-size: 0.8rem;
margin: 0;
line-height: 1.5;
}
.redirectMessage {
color: var(--text-secondary);
font-size: 0.875rem;
margin-top: 0.5rem !important;
}
/* Responsive */
@media (max-width: 500px) {
.card {

View file

@ -4,22 +4,26 @@
* 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)
* Flow:
* - Validates the invitation token
* - If user is authenticated: Accept invitation directly
* - If user is not authenticated: Store token and redirect to login/register
* The invitation will be accepted after successful authentication
*/
import React, { useState, useEffect } from 'react';
import { useParams, useNavigate, Link } from 'react-router-dom';
import { useInvitations, type InvitationValidation, type RegisterAndAcceptData } from '../hooks/useInvitations';
// Note: useAuth not needed for InvitePage
import { FaCheckCircle, FaTimesCircle, FaSpinner, FaEnvelope, FaUser, FaLock } from 'react-icons/fa';
import { useInvitations, type InvitationValidation } from '../hooks/useInvitations';
import { FaCheckCircle, FaTimesCircle, FaSpinner, FaSignInAlt, FaUserPlus } from 'react-icons/fa';
import styles from './InvitePage.module.css';
// Key for storing pending invitation token
export const PENDING_INVITATION_KEY = 'pendingInvitationToken';
export const InvitePage: React.FC = () => {
const { token } = useParams<{ token: string }>();
const navigate = useNavigate();
const { validateInvitation, acceptInvitation, registerAndAccept } = useInvitations();
const { validateInvitation, acceptInvitation } = useInvitations();
// Check if user has auth token (simplified check)
const isAuthenticated = !!sessionStorage.getItem('auth_authority');
@ -31,17 +35,6 @@ export const InvitePage: React.FC = () => {
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 () => {
@ -55,14 +48,17 @@ export const InvitePage: React.FC = () => {
setValidation(result);
setValidating(false);
// Update form with token
setFormData(prev => ({ ...prev, token }));
// If invitation is valid but user is not authenticated,
// store the token for later use after login/registration
if (result.valid && !isAuthenticated) {
sessionStorage.setItem(PENDING_INVITATION_KEY, token);
}
};
validate();
}, [token, validateInvitation]);
}, [token, validateInvitation, isAuthenticated]);
// Auto-accept if already logged in
// Accept invitation (for authenticated users)
const handleAccept = async () => {
if (!token) return;
@ -72,6 +68,8 @@ export const InvitePage: React.FC = () => {
const result = await acceptInvitation(token);
if (result.success) {
// Clear pending invitation token
sessionStorage.removeItem(PENDING_INVITATION_KEY);
setSuccess(true);
// Redirect to dashboard after 2 seconds
setTimeout(() => {
@ -84,44 +82,20 @@ export const InvitePage: React.FC = () => {
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;
// Handle redirect to login (stores token first)
const handleLoginRedirect = () => {
if (token) {
sessionStorage.setItem(PENDING_INVITATION_KEY, token);
}
// 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);
navigate('/login', { state: { from: { pathname: `/invite/${token}` } } });
};
// Handle form field changes
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
// Handle redirect to register (stores token first)
const handleRegisterRedirect = () => {
if (token) {
sessionStorage.setItem(PENDING_INVITATION_KEY, token);
}
navigate('/register', { state: { from: { pathname: `/invite/${token}` }, pendingInvitation: true } });
};
// Loading state
@ -164,11 +138,8 @@ export const InvitePage: React.FC = () => {
<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>
<p>Sie wurden erfolgreich zum Mandanten hinzugefügt.</p>
<p className={styles.redirectMessage}>Sie werden weitergeleitet...</p>
</div>
</div>
</div>
@ -186,6 +157,12 @@ export const InvitePage: React.FC = () => {
</div>
<div className={styles.inviteInfo}>
{validation.mandateName && (
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Mandant:</span>
<span className={styles.infoValue}>{validation.mandateName}</span>
</div>
)}
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Status:</span>
<span className={styles.infoValue}>Angemeldet</span>
@ -227,13 +204,32 @@ export const InvitePage: React.FC = () => {
);
}
// Not authenticated - show registration form or login option
// Not authenticated - show login/register options (NO inline registration form)
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>
<p>Sie wurden eingeladen, einem Mandanten beizutreten.</p>
</div>
<div className={styles.inviteInfo}>
{validation.mandateName && (
<div className={styles.infoRow}>
<span className={styles.infoLabel}>Mandant:</span>
<span className={styles.infoValue}>{validation.mandateName}</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>
<div className={styles.authPrompt}>
<p>Bitte melden Sie sich an oder erstellen Sie ein Konto, um die Einladung anzunehmen.</p>
</div>
{error && (
@ -242,120 +238,31 @@ export const InvitePage: React.FC = () => {
</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 className={styles.authActions}>
<button
className={styles.primaryButton}
onClick={handleLoginRedirect}
>
<FaSignInAlt /> Anmelden
</button>
<div className={styles.divider}>
<span>oder</span>
</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>
<button
className={styles.secondaryButton}
onClick={handleRegisterRedirect}
>
<FaUserPlus /> Neues Konto erstellen
</button>
</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 className={styles.authInfo}>
<p>
Sie können sich mit Ihrem bestehenden Konto anmelden oder ein neues erstellen.
Die Einladung wird automatisch nach der Anmeldung akzeptiert.
</p>
</div>
</div>
</div>

View file

@ -264,6 +264,25 @@ button:disabled {
font-family: var(--font-family);
}
.invitationNotice {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background-color: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.3);
border-radius: 8px;
margin-bottom: 16px;
font-size: 0.9rem;
color: #93c5fd;
}
.invitationIcon {
flex-shrink: 0;
font-size: 1.2rem;
color: #3b82f6;
}
.passwordResetLink {
display: flex;
justify-content: center;

View file

@ -1,9 +1,10 @@
import { useNavigate, useLocation } from 'react-router-dom';
import { useState, useEffect } from 'react';
import { FaGoogle, FaMicrosoft } from 'react-icons/fa';
import { FaGoogle, FaMicrosoft, FaEnvelopeOpenText } from 'react-icons/fa';
import { useAuth, useMsalAuth, useGoogleAuth } from '../hooks/useAuthentication';
import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
import { PENDING_INVITATION_KEY } from './InvitePage';
import styles from './Login.module.css';
@ -19,6 +20,10 @@ function Login() {
const { loginWithMsal, error: msalError, isLoading: isMsalLoading } = useMsalAuth();
const { loginWithGoogle, error: googleError, isLoading: isGoogleLoading } = useGoogleAuth();
// Check for pending invitation
const pendingInvitationToken = sessionStorage.getItem(PENDING_INVITATION_KEY);
const hasPendingInvitation = !!pendingInvitationToken;
// Get the page the user was trying to visit
const from = location.state?.from?.pathname || "/";
@ -50,13 +55,23 @@ function Login() {
return () => clearTimeout(timer);
}, []);
// Handle redirect after successful login
const handleSuccessfulLogin = () => {
// If there's a pending invitation, redirect to accept it
if (pendingInvitationToken) {
navigate(`/invite/${pendingInvitationToken}`, { replace: true });
} else {
navigate(from, { replace: true });
}
};
const handleMsalLogin = async () => {
try {
console.log("Attempting MSAL login...");
const response = await loginWithMsal();
console.log("MSAL login successful:", response);
navigate(from, { replace: true });
handleSuccessfulLogin();
} catch (error) {
console.error("MSAL login failed:", error);
}
@ -67,7 +82,7 @@ function Login() {
console.log("Attempting Google login...");
const response = await loginWithGoogle();
console.log("Google login successful:", response);
navigate(from, { replace: true });
handleSuccessfulLogin();
} catch (error) {
console.error("Google login failed:", error);
}
@ -78,9 +93,8 @@ function Login() {
try {
console.log("Attempting login with:", username);
await login(username, password);
console.log("Login successful, navigating to:", from);
// Only navigate if login was successful
navigate(from, { replace: true });
console.log("Login successful");
handleSuccessfulLogin();
} catch (error) {
console.error("Login failed:", error);
// Stay on login page to show error message
@ -100,6 +114,14 @@ function Login() {
<div className={styles.loginSection}>
<div className={styles.loginBox}>
<div className={styles.loginForm}>
{/* Pending invitation notice */}
{hasPendingInvitation && (
<div className={styles.invitationNotice}>
<FaEnvelopeOpenText className={styles.invitationIcon} />
<span>Sie haben eine ausstehende Einladung. Bitte melden Sie sich an, um diese anzunehmen.</span>
</div>
)}
{(loginError || msalError || googleError) && (
<div className={styles.error}>{loginError || msalError || googleError}</div>
)}
@ -191,7 +213,7 @@ function Login() {
<span>Du hast noch keinen Konto?</span>
<button
className={styles.textButton}
onClick={() => navigate("/register")}
onClick={() => navigate("/register", { state: location.state })}
>
Registrieren
</button>
@ -204,4 +226,4 @@ function Login() {
);
}
export default Login;
export default Login;

View file

@ -240,6 +240,25 @@ button:disabled {
margin-bottom: 10px;
}
.invitationNotice {
display: flex;
align-items: center;
gap: 12px;
padding: 12px 16px;
background-color: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.3);
border-radius: 12px;
margin-bottom: 16px;
font-size: 0.85rem;
color: #93c5fd;
}
.invitationIcon {
flex-shrink: 0;
font-size: 1.2rem;
color: #3b82f6;
}
.infoMessage {
background-color: rgba(59, 130, 246, 0.1);
border: 1px solid rgba(59, 130, 246, 0.3);

View file

@ -1,9 +1,11 @@
import { useState, useEffect } from 'react';
import { useNavigate } from 'react-router-dom';
import { useNavigate, useLocation } from 'react-router-dom';
import { FaEnvelopeOpenText } from 'react-icons/fa';
import styles from './Register.module.css';
import { useRegister, useMsalRegister, useUsernameAvailability } from '../hooks/useAuthentication';
import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
import { PENDING_INVITATION_KEY } from './InvitePage';
interface RegisterFormData {
username: string;
@ -13,6 +15,7 @@ interface RegisterFormData {
function Register() {
const navigate = useNavigate();
const location = useLocation();
const { register, error: registerError, isLoading } = useRegister();
const { error: msalError } = useMsalRegister();
const { checkAvailability, isChecking, error: availabilityError } = useUsernameAvailability();
@ -28,6 +31,10 @@ function Register() {
const [fullNameFocused, setFullNameFocused] = useState(false);
const [usernameHighlight, setUsernameHighlight] = useState(false);
// Check for pending invitation
const pendingInvitationToken = sessionStorage.getItem(PENDING_INVITATION_KEY);
const hasPendingInvitation = !!pendingInvitationToken;
// Set page title and generate CSRF token
useEffect(() => {
document.title = "PowerOn AI Platform - Registrieren";
@ -89,15 +96,23 @@ function Register() {
// Username is available, proceed with registration (no password - magic link flow)
await register(formData);
// Build success message
let message = 'Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail (auch den Spam-Ordner) für den Link zum Setzen Ihres Passworts.';
if (hasPendingInvitation) {
message += ' Nach dem Setzen Ihres Passworts können Sie sich anmelden und Ihre Einladung annehmen.';
}
// Show success message instead of immediate redirect
setSuccessMessage('Registrierung erfolgreich! Bitte prüfen Sie Ihre E-Mail (auch den Spam-Ordner) für den Link zum Setzen Ihres Passworts.');
setSuccessMessage(message);
// Redirect to login page after delay
setTimeout(() => {
navigate('/login', {
state: {
registered: true,
message: 'Registrierung erfolgreich. Bitte prüfen Sie Ihre E-Mail für den Passwort-Link.'
message: 'Registrierung erfolgreich. Bitte prüfen Sie Ihre E-Mail für den Passwort-Link.',
// Pass along invitation state
...(location.state || {})
}
});
}, 5000);
@ -127,6 +142,14 @@ function Register() {
<div className={styles.loginSection}>
<div className={styles.loginBox}>
<div className={styles.loginForm}>
{/* Pending invitation notice */}
{hasPendingInvitation && !successMessage && (
<div className={styles.invitationNotice}>
<FaEnvelopeOpenText className={styles.invitationIcon} />
<span>Sie haben eine ausstehende Einladung. Nach der Registrierung und Anmeldung können Sie diese annehmen.</span>
</div>
)}
{getErrorMessage() && (
<div className={styles.error}>{getErrorMessage()}</div>
)}
@ -203,7 +226,7 @@ function Register() {
<span>Bereits registriert?</span>
<button
className={styles.textButton}
onClick={() => navigate("/login")}
onClick={() => navigate("/login", { state: location.state })}
>
Jetzt anmelden
</button>
@ -216,4 +239,4 @@ function Register() {
);
}
export default Register;
export default Register;

View file

@ -154,6 +154,13 @@
transition: all 0.2s ease;
}
.linkButton {
text-decoration: none;
display: inline-flex;
align-items: center;
justify-content: center;
}
.button:hover {
background: var(--surface-color, #f5f5f5);
border-color: var(--border-color, #c0c0c0);

View file

@ -5,6 +5,7 @@
*/
import React, { useState, useCallback } from 'react';
import { Link } from 'react-router-dom';
import { useLanguage } from '../providers/language/LanguageContext';
import { useCurrentUser, useUser } from '../hooks/useUsers';
import { setUserDataCache, getUserDataCache } from '../utils/userCache';
@ -320,6 +321,25 @@ export const SettingsPage: React.FC = () => {
)}
</section>
{/* Datenschutz */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Datenschutz</h2>
<div className={styles.settingRow}>
<div className={styles.settingInfo}>
<label className={styles.settingLabel}>GDPR / Privacy</label>
<p className={styles.settingDescription}>
Data export, portability and account deletion.
</p>
</div>
<div className={styles.settingControl}>
<Link className={`${styles.button} ${styles.linkButton}`} to="/gdpr">
Open GDPR page
</Link>
</div>
</div>
</section>
{/* Info */}
<section className={styles.section}>
<h2 className={styles.sectionTitle}>Über</h2>

View file

@ -179,15 +179,15 @@ export const AdminInvitationsPage: React.FC = () => {
}
};
// 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 delete invitation by ID (for DeleteActionButton)
// Note: DeleteActionButton handles confirmation UI, so no window.confirm here
const handleDeleteInvitation = async (invitationId: string): Promise<boolean> => {
if (!selectedMandateId) return false;
const result = await revokeInvitation(selectedMandateId, invitationId);
if (!result.success) {
alert(result.error || 'Fehler beim Widerrufen der Einladung');
}
return result.success;
};
// Handle show URL
@ -352,9 +352,9 @@ export const AdminInvitationsPage: React.FC = () => {
title: 'Einladungs-Link anzeigen',
}
]}
onDelete={handleRevokeInvitation}
hookData={{
refetch: fetchInvitations,
handleDelete: handleDeleteInvitation,
refetch: () => fetchInvitations(selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed }),
pagination,
}}
emptyMessage="Keine Einladungen gefunden"

View file

@ -0,0 +1,658 @@
/**
* AdminUserAccessOverviewPage
*
* Admin page for viewing comprehensive user access permissions.
* Shows what pages a user can see and what data they can access.
*/
import React, { useState, useEffect } from 'react';
import { FaSync, FaUserShield, FaEye, FaDatabase, FaCube, FaChevronDown, FaChevronRight, FaCheckCircle, FaTimesCircle, FaInfoCircle } from 'react-icons/fa';
import api from '../../api';
import styles from './Admin.module.css';
interface UserOption {
id: string;
username: string;
email: string;
fullName: string;
isSysAdmin: boolean;
enabled: boolean;
}
interface RoleInfo {
id: string;
roleLabel: string;
description: { [key: string]: string };
scope: 'global' | 'mandate' | 'instance';
mandateId?: string;
featureInstanceId?: string;
source: string;
sourceMandateId?: string;
sourceMandateName?: string;
sourceInstanceId?: string;
sourceInstanceLabel?: string;
}
interface AccessEntry {
item: string;
view?: boolean;
read?: string;
create?: string;
update?: string;
delete?: string;
grantedByRoleLabels: string[];
roleScope: string;
}
interface MandateInfo {
id: string;
name: string;
roleIds: string[];
featureInstances: {
id: string;
label: string;
featureCode: string;
featureLabel: { [key: string]: string };
roleIds: string[];
}[];
}
interface UserAccessOverview {
user: UserOption;
isSysAdmin: boolean;
sysAdminNote?: string;
roles: RoleInfo[];
mandates: MandateInfo[];
uiAccess: AccessEntry[];
dataAccess: AccessEntry[];
resourceAccess: AccessEntry[];
}
type TabId = 'overview' | 'ui' | 'data' | 'resources';
export const AdminUserAccessOverviewPage: React.FC = () => {
const [users, setUsers] = useState<UserOption[]>([]);
const [selectedUserId, setSelectedUserId] = useState<string>('');
const [overview, setOverview] = useState<UserAccessOverview | null>(null);
const [loading, setLoading] = useState(false);
const [loadingUsers, setLoadingUsers] = useState(true);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState<TabId>('overview');
const [expandedRoles, setExpandedRoles] = useState<Set<string>>(new Set());
const [expandedMandates, setExpandedMandates] = useState<Set<string>>(new Set());
// Fetch users list on mount
useEffect(() => {
const fetchUsers = async () => {
try {
setLoadingUsers(true);
const response = await api.get('/api/admin/user-access-overview/users');
setUsers(response.data);
} catch (err: any) {
setError(err?.response?.data?.detail || err?.message || 'Failed to fetch users');
} finally {
setLoadingUsers(false);
}
};
fetchUsers();
}, []);
// Fetch access overview when user is selected
useEffect(() => {
if (!selectedUserId) {
setOverview(null);
return;
}
const fetchOverview = async () => {
try {
setLoading(true);
setError(null);
const response = await api.get(`/api/admin/user-access-overview/${selectedUserId}`);
const data = response.data;
setOverview(data);
// Auto-expand all mandates
setExpandedMandates(new Set(data.mandates?.map((m: MandateInfo) => m.id) || []));
} catch (err: any) {
setError(err?.response?.data?.detail || err?.message || 'Failed to fetch overview');
setOverview(null);
} finally {
setLoading(false);
}
};
fetchOverview();
}, [selectedUserId]);
const toggleRole = (roleId: string) => {
setExpandedRoles(prev => {
const newSet = new Set(prev);
if (newSet.has(roleId)) {
newSet.delete(roleId);
} else {
newSet.add(roleId);
}
return newSet;
});
};
const toggleMandate = (mandateId: string) => {
setExpandedMandates(prev => {
const newSet = new Set(prev);
if (newSet.has(mandateId)) {
newSet.delete(mandateId);
} else {
newSet.add(mandateId);
}
return newSet;
});
};
const getScopeColor = (scope: string): string => {
switch (scope) {
case 'instance': return '#10b981';
case 'mandate': return '#3b82f6';
case 'global': return '#8b5cf6';
default: return '#6b7280';
}
};
const getAccessLevelColor = (level: string): string => {
switch (level) {
case 'ALL': return '#10b981';
case 'GROUP': return '#3b82f6';
case 'MY': return '#f59e0b';
case 'NONE': return '#ef4444';
default: return '#6b7280';
}
};
const renderOverviewTab = () => {
if (!overview) return null;
return (
<div className={styles.scrollableContent}>
{/* SysAdmin Notice */}
{overview.isSysAdmin && (
<div className={styles.infoBox} style={{ background: '#fef3c7', borderColor: '#f59e0b' }}>
<FaInfoCircle style={{ marginRight: '0.5rem', color: '#f59e0b' }} />
<span>{overview.sysAdminNote || 'Dieser Benutzer ist SysAdmin und hat vollen Systemzugriff.'}</span>
</div>
)}
{/* Mandates & Feature Instances */}
<h3 style={{ marginBottom: '1rem', color: 'var(--text-primary)' }}>Mandate & Feature-Instanzen</h3>
{overview.mandates.length === 0 ? (
<p className={styles.emptyHint}>Keine Mandate-Zuordnungen vorhanden.</p>
) : (
<div className={styles.rolesList}>
{overview.mandates.map(mandate => (
<div key={mandate.id} className={styles.roleCard}>
<div
className={styles.roleHeader}
onClick={() => toggleMandate(mandate.id)}
>
<div className={styles.roleInfo}>
{expandedMandates.has(mandate.id) ? <FaChevronDown className={styles.expandIcon} /> : <FaChevronRight className={styles.expandIcon} />}
<span className={styles.roleLabel}>{mandate.name}</span>
<span className={styles.roleDescription}>
{mandate.featureInstances.length} Feature-Instanz(en)
</span>
</div>
</div>
{expandedMandates.has(mandate.id) && (
<div className={styles.roleContent}>
{mandate.featureInstances.length === 0 ? (
<p className={styles.emptyHint}>Keine Feature-Instanzen zugewiesen.</p>
) : (
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.5rem' }}>
{mandate.featureInstances.map(instance => (
<div
key={instance.id}
style={{
padding: '0.75rem',
background: 'var(--bg-secondary)',
borderRadius: '6px',
border: '1px solid var(--border-color)',
}}
>
<div style={{ fontWeight: 500, marginBottom: '0.25rem' }}>
{instance.label}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)' }}>
Feature: {instance.featureLabel?.de || instance.featureCode}
</div>
<div style={{ fontSize: '0.75rem', color: 'var(--text-secondary)', marginTop: '0.25rem' }}>
Rollen: {instance.roleIds.length > 0
? overview.roles
.filter(r => instance.roleIds.includes(r.id))
.map(r => r.roleLabel)
.join(', ')
: 'Keine'
}
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
))}
</div>
)}
{/* Roles */}
<h3 style={{ marginTop: '2rem', marginBottom: '1rem', color: 'var(--text-primary)' }}>Zugewiesene Rollen</h3>
{overview.roles.length === 0 ? (
<p className={styles.emptyHint}>Keine Rollen zugewiesen.</p>
) : (
<div className={styles.rolesList}>
{overview.roles.map(role => (
<div key={role.id} className={styles.roleCard}>
<div
className={styles.roleHeader}
onClick={() => toggleRole(role.id)}
>
<div className={styles.roleInfo}>
{expandedRoles.has(role.id) ? <FaChevronDown className={styles.expandIcon} /> : <FaChevronRight className={styles.expandIcon} />}
<span className={styles.roleLabel}>{role.roleLabel}</span>
<span
className={styles.badge}
style={{
background: getScopeColor(role.scope),
color: 'white',
marginLeft: '0.5rem'
}}
>
{role.scope}
</span>
</div>
</div>
{expandedRoles.has(role.id) && (
<div className={styles.roleContent}>
<div style={{ fontSize: '0.875rem' }}>
<p><strong>Beschreibung:</strong> {role.description?.de || role.description?.en || '-'}</p>
<p><strong>Quelle:</strong> {role.source === 'mandate'
? `Mandate: ${role.sourceMandateName}`
: `Feature-Instanz: ${role.sourceInstanceLabel}`
}</p>
</div>
</div>
)}
</div>
))}
</div>
)}
</div>
);
};
const renderUiAccessTab = () => {
if (!overview) return null;
return (
<div className={styles.scrollableContent}>
<div className={styles.infoBox}>
<FaInfoCircle style={{ marginRight: '0.5rem', color: 'var(--primary-color)' }} />
<span>UI-Zugriffsrechte bestimmen, welche Seiten und Views der Benutzer sehen kann.</span>
</div>
{overview.uiAccess.length === 0 ? (
<div className={styles.emptyState}>
<FaEye className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine UI-Berechtigungen</h3>
<p className={styles.emptyDescription}>
Diesem Benutzer wurden keine expliziten UI-Berechtigungen zugewiesen.
</p>
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--border-color)' }}>
<th style={{ textAlign: 'left', padding: '0.75rem', color: 'var(--text-secondary)' }}>UI-Element</th>
<th style={{ textAlign: 'center', padding: '0.75rem', color: 'var(--text-secondary)', width: '80px' }}>Sichtbar</th>
<th style={{ textAlign: 'left', padding: '0.75rem', color: 'var(--text-secondary)' }}>Gewährt durch</th>
</tr>
</thead>
<tbody>
{overview.uiAccess.map((entry, idx) => (
<tr
key={idx}
style={{
borderBottom: '1px solid var(--border-color)',
background: idx % 2 === 0 ? 'transparent' : 'var(--bg-secondary)'
}}
>
<td style={{ padding: '0.75rem', fontFamily: 'monospace', fontSize: '0.875rem' }}>
{entry.item}
</td>
<td style={{ padding: '0.75rem', textAlign: 'center' }}>
{entry.view ? (
<FaCheckCircle style={{ color: '#10b981' }} />
) : (
<FaTimesCircle style={{ color: '#ef4444' }} />
)}
</td>
<td style={{ padding: '0.75rem', fontSize: '0.875rem', color: 'var(--text-secondary)' }}>
{entry.grantedByRoleLabels?.join(', ') || '-'}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
};
const renderDataAccessTab = () => {
if (!overview) return null;
return (
<div className={styles.scrollableContent}>
<div className={styles.infoBox}>
<FaInfoCircle style={{ marginRight: '0.5rem', color: 'var(--primary-color)' }} />
<span>
Daten-Zugriffsrechte: <strong>ALL</strong> = Alle Datensätze, <strong>GROUP</strong> = Gruppen-Datensätze,
<strong> MY</strong> = Eigene Datensätze, <strong>NONE</strong> = Kein Zugriff
</span>
</div>
{overview.dataAccess.length === 0 ? (
<div className={styles.emptyState}>
<FaDatabase className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Daten-Berechtigungen</h3>
<p className={styles.emptyDescription}>
Diesem Benutzer wurden keine expliziten Daten-Berechtigungen zugewiesen.
</p>
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--border-color)' }}>
<th style={{ textAlign: 'left', padding: '0.75rem', color: 'var(--text-secondary)' }}>Tabelle/Feld</th>
<th style={{ textAlign: 'center', padding: '0.75rem', color: 'var(--text-secondary)', width: '70px' }}>Lesen</th>
<th style={{ textAlign: 'center', padding: '0.75rem', color: 'var(--text-secondary)', width: '70px' }}>Erstellen</th>
<th style={{ textAlign: 'center', padding: '0.75rem', color: 'var(--text-secondary)', width: '70px' }}>Update</th>
<th style={{ textAlign: 'center', padding: '0.75rem', color: 'var(--text-secondary)', width: '70px' }}>Löschen</th>
<th style={{ textAlign: 'left', padding: '0.75rem', color: 'var(--text-secondary)' }}>Gewährt durch</th>
</tr>
</thead>
<tbody>
{overview.dataAccess.map((entry, idx) => (
<tr
key={idx}
style={{
borderBottom: '1px solid var(--border-color)',
background: idx % 2 === 0 ? 'transparent' : 'var(--bg-secondary)'
}}
>
<td style={{ padding: '0.75rem', fontFamily: 'monospace', fontSize: '0.875rem' }}>
{entry.item}
</td>
<td style={{ padding: '0.75rem', textAlign: 'center' }}>
<span
className={styles.badge}
style={{
background: getAccessLevelColor(entry.read || '-'),
color: 'white',
fontSize: '0.7rem'
}}
>
{entry.read || '-'}
</span>
</td>
<td style={{ padding: '0.75rem', textAlign: 'center' }}>
<span
className={styles.badge}
style={{
background: getAccessLevelColor(entry.create || '-'),
color: 'white',
fontSize: '0.7rem'
}}
>
{entry.create || '-'}
</span>
</td>
<td style={{ padding: '0.75rem', textAlign: 'center' }}>
<span
className={styles.badge}
style={{
background: getAccessLevelColor(entry.update || '-'),
color: 'white',
fontSize: '0.7rem'
}}
>
{entry.update || '-'}
</span>
</td>
<td style={{ padding: '0.75rem', textAlign: 'center' }}>
<span
className={styles.badge}
style={{
background: getAccessLevelColor(entry.delete || '-'),
color: 'white',
fontSize: '0.7rem'
}}
>
{entry.delete || '-'}
</span>
</td>
<td style={{ padding: '0.75rem', fontSize: '0.875rem', color: 'var(--text-secondary)' }}>
{entry.grantedByRoleLabels?.join(', ') || '-'}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
};
const renderResourceAccessTab = () => {
if (!overview) return null;
return (
<div className={styles.scrollableContent}>
<div className={styles.infoBox}>
<FaInfoCircle style={{ marginRight: '0.5rem', color: 'var(--primary-color)' }} />
<span>Ressourcen-Zugriffsrechte bestimmen, welche System-Ressourcen (z.B. AI-Modelle) der Benutzer verwenden kann.</span>
</div>
{overview.resourceAccess.length === 0 ? (
<div className={styles.emptyState}>
<FaCube className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Ressourcen-Berechtigungen</h3>
<p className={styles.emptyDescription}>
Diesem Benutzer wurden keine expliziten Ressourcen-Berechtigungen zugewiesen.
</p>
</div>
) : (
<table style={{ width: '100%', borderCollapse: 'collapse' }}>
<thead>
<tr style={{ borderBottom: '2px solid var(--border-color)' }}>
<th style={{ textAlign: 'left', padding: '0.75rem', color: 'var(--text-secondary)' }}>Ressource</th>
<th style={{ textAlign: 'center', padding: '0.75rem', color: 'var(--text-secondary)', width: '80px' }}>Zugriff</th>
<th style={{ textAlign: 'left', padding: '0.75rem', color: 'var(--text-secondary)' }}>Gewährt durch</th>
</tr>
</thead>
<tbody>
{overview.resourceAccess.map((entry, idx) => (
<tr
key={idx}
style={{
borderBottom: '1px solid var(--border-color)',
background: idx % 2 === 0 ? 'transparent' : 'var(--bg-secondary)'
}}
>
<td style={{ padding: '0.75rem', fontFamily: 'monospace', fontSize: '0.875rem' }}>
{entry.item}
</td>
<td style={{ padding: '0.75rem', textAlign: 'center' }}>
{entry.view ? (
<FaCheckCircle style={{ color: '#10b981' }} />
) : (
<FaTimesCircle style={{ color: '#ef4444' }} />
)}
</td>
<td style={{ padding: '0.75rem', fontSize: '0.875rem', color: 'var(--text-secondary)' }}>
{entry.grantedByRoleLabels?.join(', ') || '-'}
</td>
</tr>
))}
</tbody>
</table>
)}
</div>
);
};
if (error && !overview) {
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={() => window.location.reload()}
>
<FaSync /> Erneut versuchen
</button>
</div>
</div>
);
}
return (
<div className={styles.adminPage}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Benutzer-Zugriffsübersicht</h1>
<p className={styles.pageSubtitle}>Zeigt alle Berechtigungen eines Benutzers an</p>
</div>
</div>
{/* User Selection */}
<div className={styles.filterSection}>
<div className={styles.filterGroup}>
<label className={styles.filterLabel}>
<FaUserShield style={{ marginRight: '0.5rem' }} />
Benutzer auswählen:
</label>
<select
className={styles.filterSelect}
value={selectedUserId}
onChange={(e) => setSelectedUserId(e.target.value)}
disabled={loadingUsers}
style={{ minWidth: '300px' }}
>
<option value="">-- Benutzer wählen --</option>
{users.map(user => (
<option key={user.id} value={user.id}>
{user.fullName || user.username} ({user.email})
{user.isSysAdmin && ' [SysAdmin]'}
</option>
))}
</select>
</div>
{selectedUserId && (
<button
className={styles.secondaryButton}
onClick={() => setSelectedUserId(selectedUserId)}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
)}
</div>
{/* Content */}
{!selectedUserId ? (
<div className={styles.emptyState}>
<FaUserShield className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Benutzer auswählen</h3>
<p className={styles.emptyDescription}>
Wählen Sie einen Benutzer aus, um dessen Zugriffsberechtigungen anzuzeigen.
</p>
</div>
) : loading ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Zugriffsübersicht...</span>
</div>
) : overview ? (
<>
{/* User Info */}
<div className={styles.infoBox} style={{ marginBottom: '1rem' }}>
<strong>{overview.user.fullName || overview.user.username}</strong>
<span style={{ margin: '0 1rem', color: 'var(--text-secondary)' }}>|</span>
<span>{overview.user.email}</span>
{overview.isSysAdmin && (
<>
<span style={{ margin: '0 1rem', color: 'var(--text-secondary)' }}>|</span>
<span className={styles.badge} style={{ background: '#f59e0b', color: 'white' }}>
SysAdmin
</span>
</>
)}
</div>
{/* Tabs */}
<div style={{
display: 'flex',
gap: '0.5rem',
marginBottom: '1rem',
borderBottom: '1px solid var(--border-color)',
paddingBottom: '0.5rem'
}}>
<button
className={activeTab === 'overview' ? styles.primaryButton : styles.secondaryButton}
onClick={() => setActiveTab('overview')}
style={{ padding: '0.5rem 1rem' }}
>
<FaUserShield /> Übersicht
</button>
<button
className={activeTab === 'ui' ? styles.primaryButton : styles.secondaryButton}
onClick={() => setActiveTab('ui')}
style={{ padding: '0.5rem 1rem' }}
>
<FaEye /> UI-Zugriff ({overview.uiAccess.length})
</button>
<button
className={activeTab === 'data' ? styles.primaryButton : styles.secondaryButton}
onClick={() => setActiveTab('data')}
style={{ padding: '0.5rem 1rem' }}
>
<FaDatabase /> Daten-Zugriff ({overview.dataAccess.length})
</button>
<button
className={activeTab === 'resources' ? styles.primaryButton : styles.secondaryButton}
onClick={() => setActiveTab('resources')}
style={{ padding: '0.5rem 1rem' }}
>
<FaCube /> Ressourcen ({overview.resourceAccess.length})
</button>
</div>
{/* Tab Content */}
<div className={styles.tableContainer}>
{activeTab === 'overview' && renderOverviewTab()}
{activeTab === 'ui' && renderUiAccessTab()}
{activeTab === 'data' && renderDataAccessTab()}
{activeTab === 'resources' && renderResourceAccessTab()}
</div>
</>
) : null}
</div>
);
};
export default AdminUserAccessOverviewPage;

View file

@ -12,4 +12,5 @@ export { AdminInvitationsPage } from './AdminInvitationsPage';
export { AdminMandateRolesPage } from './AdminMandateRolesPage';
export { AdminFeatureRolesPage } from './AdminFeatureRolesPage';
export { AdminFeatureInstanceUsersPage } from './AdminFeatureInstanceUsersPage';
export { AdminMandateRolePermissionsPage } from './AdminMandateRolePermissionsPage';
export { AdminMandateRolePermissionsPage } from './AdminMandateRolePermissionsPage';
export { AdminUserAccessOverviewPage } from './AdminUserAccessOverviewPage';

View file

@ -0,0 +1,658 @@
/**
* TrusteeExpenseImportView
*
* Setup page for automatic expense import from SharePoint PDFs.
* Allows users to connect their Microsoft account, select a SharePoint folder,
* and activate daily automation for expense extraction.
*/
import React, { useState, useEffect, useCallback } from 'react';
import { useCurrentInstance } from '../../../hooks/useCurrentInstance';
import { useConnections } from '../../../hooks/useConnections';
import { useToast } from '../../../contexts/ToastContext';
import api from '../../../api';
import styles from './TrusteeViews.module.css';
// Default extraction prompt (from automation template)
const DEFAULT_EXTRACTION_PROMPT = `Du bist ein Spezialist für die Extraktion von Spesendaten aus PDF-Dokumenten.
AUFGABE:
Extrahiere alle Speseneinträge aus dem bereitgestellten PDF-Dokument und gib sie im CSV-Format zurück.
WICHTIGE REGELN:
1. Pro MwSt-Prozentsatz einen separaten Datensatz erstellen
2. Alle Datensätze zusammen müssen den Gesamtbetrag des Dokuments ergeben
3. Der gesamte extrahierte Text des Dokuments muss im Feld "desc" erfasst werden
4. Feld "company" enthält den Lieferanten/Verkäufer der Buchung
5. Tags müssen aus dieser Liste gewählt werden: customer, meeting, license, subscription, fuel, food, material
- Mehrere zutreffende Tags mit Komma trennen
CSV-SPALTEN (in dieser Reihenfolge):
valuta,transactionDateTime,company,desc,tags,bookingCurrency,bookingAmount,originalCurrency,originalAmount,vatPercentage,vatAmount
DATENFORMAT:
- valuta: YYYY-MM-DD (Valutadatum)
- transactionDateTime: Unix-Timestamp in Sekunden (Transaktionszeitpunkt)
- company: Lieferant/Verkäufer Name
- desc: Vollständiger extrahierter Text des Dokuments
- tags: Komma-getrennte Tags aus der erlaubten Liste
- bookingCurrency: Währungscode (CHF, EUR, USD, GBP)
- bookingAmount: Buchungsbetrag als Dezimalzahl
- originalCurrency: Original-Währungscode
- originalAmount: Original-Betrag als Dezimalzahl
- vatPercentage: MwSt-Prozentsatz (z.B. 8.1 für 8.1%)
- vatAmount: MwSt-Betrag als Dezimalzahl
HINWEISE:
- Wenn nur ein MwSt-Satz vorhanden ist, einen Datensatz erstellen
- Wenn mehrere MwSt-Sätze vorhanden sind, separate Datensätze erstellen
- Bei fehlenden Informationen: leeres Feld oder Standardwert`;
interface SiteOption {
value: string;
label: string;
siteId: string;
siteName: string;
webUrl: string;
path: string;
}
interface FolderOption {
value: string;
label: string;
siteId: string;
folderName: string;
path: string;
}
interface Connection {
id: string;
type?: string;
authority: string;
status: string;
externalUsername?: string;
accountName?: string; // Legacy fallback
}
interface ExistingAutomation {
id: string;
label: string;
active: boolean;
schedule: string;
placeholders: Record<string, string>;
}
// Helper function to safely convert error detail to string
const parseErrorDetail = (detail: any): string => {
if (typeof detail === 'string') return detail;
if (Array.isArray(detail)) {
// FastAPI validation errors come as array of {type, loc, msg, input}
return detail.map(e => e.msg || JSON.stringify(e)).join(', ');
}
if (typeof detail === 'object' && detail !== null) {
return detail.msg || detail.message || JSON.stringify(detail);
}
return String(detail);
};
export const TrusteeExpenseImportView: React.FC = () => {
// Use instanceId/mandateId from URL params (always available)
const { instanceId, mandateId } = useCurrentInstance();
const { connections, createMicrosoftConnectionAndAuth, fetchConnections } = useConnections();
const { showSuccess, showError } = useToast();
const [msftConnections, setMsftConnections] = useState<Connection[]>([]);
const [msftConnection, setMsftConnection] = useState<Connection | null>(null);
const [siteOptions, setSiteOptions] = useState<SiteOption[]>([]);
const [folderOptions, setFolderOptions] = useState<FolderOption[]>([]);
const [selectedSite, setSelectedSite] = useState<SiteOption | null>(null);
const [currentPath, setCurrentPath] = useState<string>('');
const [selectedFolder, setSelectedFolder] = useState<string>('');
const [isLoadingSites, setIsLoadingSites] = useState(false);
const [isLoadingFolders, setIsLoadingFolders] = useState(false);
const [isActivating, setIsActivating] = useState(false);
const [isConnecting, setIsConnecting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [successMessage, setSuccessMessage] = useState<string | null>(null);
const [existingAutomation, setExistingAutomation] = useState<ExistingAutomation | null>(null);
const [isLoadingAutomation, setIsLoadingAutomation] = useState(true);
const [showInfoTooltip, setShowInfoTooltip] = useState(false);
// Find all active Microsoft connections
useEffect(() => {
const msftConns = connections.filter((c: Connection) =>
(c.type === 'msft' || c.authority === 'msft') && c.status === 'active'
);
setMsftConnections(msftConns);
// Auto-select if only one connection
if (msftConns.length === 1) {
setMsftConnection(msftConns[0]);
} else if (msftConns.length === 0) {
setMsftConnection(null);
}
// Note: When multiple connections exist, user must select manually
}, [connections]);
// Load existing automation for this feature instance
useEffect(() => {
const loadExistingAutomation = async () => {
if (!instanceId) return;
setIsLoadingAutomation(true);
try {
// Fetch all automations and filter client-side
const response = await api.get('/api/automations');
const automations = response.data?.items || response.data?.data || response.data || [];
// Find automation by label AND featureInstanceId
const expenseAutomation = automations.find((a: any) =>
a.label === 'Expense Import' && a.featureInstanceId === instanceId
);
if (expenseAutomation) {
setExistingAutomation({
id: expenseAutomation.id,
label: expenseAutomation.label,
active: expenseAutomation.active,
schedule: expenseAutomation.schedule,
placeholders: expenseAutomation.placeholders || {}
});
// Pre-fill selected folder from existing automation
if (expenseAutomation.placeholders?.sharepointFolder) {
setSelectedFolder(expenseAutomation.placeholders.sharepointFolder);
}
}
} catch (err) {
console.error('Failed to load existing automation:', err);
} finally {
setIsLoadingAutomation(false);
}
};
loadExistingAutomation();
}, [instanceId]);
// Pre-select connection from existing automation when connections are loaded
useEffect(() => {
if (!existingAutomation || msftConnections.length === 0 || msftConnection) return;
// Try to match connection from existing automation placeholders
// Format: "connection:msft:externalUsername" or just the connection ID
const savedConnectionRef = existingAutomation.placeholders?.connectionName || '';
// Try to find matching connection by externalUsername, accountName, or id
const matchingConn = msftConnections.find(c =>
savedConnectionRef.includes(c.externalUsername || '') ||
savedConnectionRef.includes(c.accountName || '') ||
savedConnectionRef.includes(c.id)
);
if (matchingConn) {
setMsftConnection(matchingConn);
} else if (msftConnections.length === 1) {
// Fallback to single connection
setMsftConnection(msftConnections[0]);
}
}, [existingAutomation, msftConnections, msftConnection]);
// Load SharePoint sites when connected
const loadSiteOptions = useCallback(async () => {
if (!msftConnection) return;
setIsLoadingSites(true);
setError(null);
try {
const response = await api.get(`/api/sharepoint/${msftConnection.id}/folder-options`);
setSiteOptions(response.data || []);
} catch (err: any) {
console.error('Failed to load sites:', err);
setError(parseErrorDetail(err.response?.data?.detail) || 'Failed to load SharePoint sites');
setSiteOptions([]);
} finally {
setIsLoadingSites(false);
}
}, [msftConnection]);
// Load folders when site is selected
const loadFolderOptions = useCallback(async (siteId: string, path: string = '') => {
if (!msftConnection || !siteId) return;
setIsLoadingFolders(true);
setError(null);
try {
const params = new URLSearchParams({ siteId });
if (path) params.append('path', path);
const response = await api.get(`/api/sharepoint/${msftConnection.id}/folder-options?${params}`);
setFolderOptions(response.data || []);
} catch (err: any) {
console.error('Failed to load folders:', err);
setError(parseErrorDetail(err.response?.data?.detail) || 'Failed to load folders');
setFolderOptions([]);
} finally {
setIsLoadingFolders(false);
}
}, [msftConnection]);
useEffect(() => {
if (msftConnection) {
loadSiteOptions();
}
}, [msftConnection, loadSiteOptions]);
// Load root folders when site changes
useEffect(() => {
if (selectedSite) {
setCurrentPath('');
setSelectedFolder('');
loadFolderOptions(selectedSite.siteId, '');
}
}, [selectedSite, loadFolderOptions]);
const handleSiteChange = (siteId: string) => {
const site = siteOptions.find(s => s.siteId === siteId);
setSelectedSite(site || null);
};
const handleFolderNavigate = (folder: FolderOption) => {
const newPath = folder.path;
setCurrentPath(newPath);
loadFolderOptions(selectedSite!.siteId, newPath);
};
const handleFolderSelect = (folder: FolderOption) => {
// Build full path: /sites/SiteName/FolderPath
const fullPath = `${selectedSite?.path || ''}/${folder.path}`;
setSelectedFolder(fullPath);
};
const handleGoUp = () => {
if (!currentPath) return;
const parts = currentPath.split('/');
parts.pop();
const parentPath = parts.join('/');
setCurrentPath(parentPath);
loadFolderOptions(selectedSite!.siteId, parentPath);
};
const handleConnect = async () => {
setIsConnecting(true);
setError(null);
try {
await createMicrosoftConnectionAndAuth();
await fetchConnections();
} catch (err: any) {
console.error('Connection failed:', err);
setError(err.message || 'Microsoft connection failed');
} finally {
setIsConnecting(false);
}
};
const handleSave = async (activate: boolean = true) => {
// Validate required fields with user feedback
if (!msftConnection) {
showError('Missing Connection', 'Please select a Microsoft connection first.');
return;
}
if (!selectedFolder) {
showError('Missing Folder', 'Please select a SharePoint folder first.');
return;
}
if (!instanceId || !mandateId) {
showError('Error', 'Feature instance not found. Please refresh the page.');
return;
}
setIsActivating(true);
setError(null);
setSuccessMessage(null);
try {
const connectionReference = `connection:msft:${msftConnection.externalUsername || msftConnection.accountName || msftConnection.id}`;
const automationData = {
label: 'Expense Import',
schedule: '0 22 * * *', // Daily at 22:00
template: JSON.stringify({
overview: "Expenses PDF to Trustee Position",
tasks: [{
id: "Task01",
title: "Extract Expenses from SharePoint PDFs",
description: "Automatic expense extraction",
objective: "Extract expense data from PDF documents",
actionList: [{
execMethod: "sharepoint",
execAction: "getExpensesFromPdf",
execParameters: {
connectionReference: connectionReference,
sharepointFolder: selectedFolder,
featureInstanceId: instanceId,
prompt: DEFAULT_EXTRACTION_PROMPT
},
execResultLabel: "expense_extraction_result"
}]
}]
}),
placeholders: {
connectionName: connectionReference,
sharepointFolder: selectedFolder,
featureInstanceId: instanceId
},
active: activate,
mandateId: mandateId,
featureInstanceId: instanceId
};
let response;
if (existingAutomation) {
// Update existing automation
response = await api.put(`/api/automations/${existingAutomation.id}`, {
...automationData,
id: existingAutomation.id,
mandateId: mandateId
});
const msg = activate
? 'Expense import automation updated and activated!'
: 'Expense import automation updated and deactivated.';
setSuccessMessage(msg);
showSuccess('Success', msg);
} else {
// Create new automation
response = await api.post('/api/automations', automationData);
const msg = 'Expense import automation created and activated! It will run daily at 22:00.';
setSuccessMessage(msg);
showSuccess('Success', msg);
}
// Update local state with response
const savedAutomation = response.data;
setExistingAutomation({
id: savedAutomation.id,
label: savedAutomation.label,
active: savedAutomation.active,
schedule: savedAutomation.schedule,
placeholders: savedAutomation.placeholders || {}
});
} catch (err: any) {
console.error('Save failed:', err);
const errorMsg = parseErrorDetail(err.response?.data?.detail) || err.message || 'Failed to save automation';
setError(errorMsg);
showError('Error', errorMsg);
} finally {
setIsActivating(false);
}
};
const handleDeactivate = async () => {
if (!existingAutomation) return;
setIsActivating(true);
setError(null);
try {
// Use dedicated PATCH endpoint for status changes
await api.patch(`/api/automations/${existingAutomation.id}/status`, {
active: false
});
setExistingAutomation(prev => prev ? { ...prev, active: false } : null);
setSuccessMessage('Expense import automation deactivated.');
showSuccess('Deactivated', 'Expense import automation deactivated.');
} catch (err: any) {
console.error('Deactivation failed:', err);
const errorMsg = parseErrorDetail(err.response?.data?.detail) || err.message || 'Failed to deactivate automation';
setError(errorMsg);
showError('Error', errorMsg);
} finally {
setIsActivating(false);
}
};
return (
<div className={styles.listView}>
<div className={styles.expenseImportSection}>
<h3 className={styles.sectionTitle}>Expense Import Setup</h3>
<p className={styles.sectionDescription}>
Connect your Microsoft account and select a SharePoint folder containing expense PDFs.
The system will automatically extract expense data daily and save it as positions.
<span
className={styles.infoIcon}
onMouseEnter={() => setShowInfoTooltip(true)}
onMouseLeave={() => setShowInfoTooltip(false)}
onClick={() => setShowInfoTooltip(!showInfoTooltip)}
title="How it works"
>
</span>
{showInfoTooltip && (
<span className={styles.infoTooltip}>
<strong>How it works:</strong>
<ul>
<li>Place expense PDF documents (receipts, invoices) in the selected SharePoint folder</li>
<li>The system runs daily at 22:00 and processes all PDF files</li>
<li>AI extracts expense data: date, amount, VAT, company, description</li>
<li>Each expense is saved as a position in this Trustee instance</li>
<li>Processed PDFs are moved to a "processed" subfolder</li>
<li>Failed PDFs are moved to an "error" subfolder</li>
<li>Maximum 50 PDFs are processed per run</li>
</ul>
</span>
)}
</p>
{error && (
<div className={styles.errorMessage}>
{error}
</div>
)}
{successMessage && (
<div className={styles.successMessage}>
{successMessage}
</div>
)}
{/* Current Status */}
{!isLoadingAutomation && existingAutomation && (
<div className={existingAutomation.active ? styles.successMessage : styles.infoBox} style={{ marginBottom: '1rem' }}>
<strong>Current Status:</strong> {existingAutomation.active ? '✓ Active' : '○ Inactive'}
{existingAutomation.placeholders?.sharepointFolder && (
<><br />Folder: {existingAutomation.placeholders.sharepointFolder}</>
)}
</div>
)}
{/* Step 1: Microsoft Connection */}
<div className={styles.setupStep}>
<div className={styles.stepNumber}>1</div>
<div className={styles.stepContent}>
<h4>Microsoft Connection</h4>
{msftConnections.length === 0 ? (
// No connections - show connect button
<button
className={styles.primaryButton}
onClick={handleConnect}
disabled={isConnecting}
>
{isConnecting ? 'Connecting...' : 'Connect Microsoft Account'}
</button>
) : msftConnections.length === 1 ? (
// Single connection - show as connected
<div className={styles.connectionStatus}>
<span className={styles.connectedIcon}></span>
<span className={styles.connectedText}>
Connected as <strong>{msftConnections[0].accountName || 'Microsoft Account'}</strong>
</span>
</div>
) : (
// Multiple connections - show dropdown
<>
<select
className={styles.folderSelect}
value={msftConnection?.id || ''}
onChange={(e) => {
const conn = msftConnections.find(c => c.id === e.target.value);
setMsftConnection(conn || null);
// Reset site/folder selection when connection changes
setSelectedSite(null);
setSiteOptions([]);
setFolderOptions([]);
setSelectedFolder('');
}}
>
<option value="">Select a Microsoft account...</option>
{msftConnections.map((conn) => (
<option key={conn.id} value={conn.id}>
{conn.accountName || conn.id}
</option>
))}
</select>
<button
className={styles.secondaryButton}
onClick={handleConnect}
disabled={isConnecting}
style={{ marginTop: '0.5rem' }}
>
{isConnecting ? 'Connecting...' : 'Add another account'}
</button>
</>
)}
</div>
</div>
{/* Step 2: SharePoint Site Selection */}
{msftConnection && (
<div className={styles.setupStep}>
<div className={styles.stepNumber}>2</div>
<div className={styles.stepContent}>
<h4>SharePoint Site</h4>
{isLoadingSites ? (
<div className={styles.loadingText}>Loading sites...</div>
) : (
<select
className={styles.folderSelect}
value={selectedSite?.siteId || ''}
onChange={(e) => handleSiteChange(e.target.value)}
>
<option value="">Select a site...</option>
{siteOptions.map((site) => (
<option key={site.siteId} value={site.siteId}>
{site.label}
</option>
))}
</select>
)}
</div>
</div>
)}
{/* Step 3: Folder Selection */}
{selectedSite && (
<div className={styles.setupStep}>
<div className={styles.stepNumber}>3</div>
<div className={styles.stepContent}>
<h4>Expense Folder</h4>
<p className={styles.activateDescription}>
Current path: <strong>{selectedSite.path}/{currentPath || '(root)'}</strong>
</p>
{isLoadingFolders ? (
<div className={styles.loadingText}>Loading folders...</div>
) : (
<div className={styles.folderBrowser}>
<div style={{ display: 'flex', gap: '0.5rem', marginBottom: '0.5rem' }}>
{currentPath && (
<button
className={styles.secondaryButton}
onClick={handleGoUp}
>
Go Up
</button>
)}
<button
className={styles.primaryButton}
onClick={() => {
// Select the current folder path
const fullPath = `${selectedSite?.path || ''}/${currentPath || ''}`.replace(/\/+$/, '');
setSelectedFolder(fullPath || selectedSite?.path || '');
}}
>
Select This Folder
</button>
</div>
<div className={styles.folderList}>
{folderOptions.map((folder) => (
<div key={folder.value} className={styles.folderItem}>
<span
className={styles.folderName}
onClick={() => handleFolderNavigate(folder)}
style={{ cursor: 'pointer', flex: 1 }}
>
📁 {folder.label}
</span>
<button
className={styles.selectButton}
onClick={() => handleFolderSelect(folder)}
>
Select
</button>
</div>
))}
{folderOptions.length === 0 && (
<div className={styles.emptyText}>No subfolders found</div>
)}
</div>
{selectedFolder && (
<p className={styles.selectedFolderText}>
Selected: <strong>{selectedFolder}</strong>
</p>
)}
</div>
)}
</div>
</div>
)}
{/* Step 4: Save & Activate */}
{selectedFolder && (
<div className={styles.setupStep}>
<div className={styles.stepNumber}>4</div>
<div className={styles.stepContent}>
<h4>{existingAutomation ? 'Update Configuration' : 'Activate Daily Import'}</h4>
<p className={styles.activateDescription}>
PDF files in <strong>{selectedFolder}</strong> will be processed daily at 22:00.
Successfully processed files will be moved to a "processed" subfolder.
</p>
<div style={{ display: 'flex', gap: '0.5rem', flexWrap: 'wrap' }}>
<button
className={styles.primaryButton}
onClick={() => handleSave(true)}
disabled={isActivating}
>
{isActivating ? 'Saving...' : (existingAutomation ? 'Save & Activate' : 'Activate Daily Import')}
</button>
{existingAutomation && existingAutomation.active && (
<button
className={styles.secondaryButton}
onClick={handleDeactivate}
disabled={isActivating}
>
Deactivate
</button>
)}
</div>
</div>
</div>
)}
</div>
</div>
);
};
export default TrusteeExpenseImportView;

View file

@ -564,7 +564,8 @@
.infoBox {
display: flex;
align-items: center;
flex-direction: column;
align-items: flex-start;
padding: 0.75rem 1rem;
background: var(--info-light, #e0f2fe);
border: 1px solid var(--info-color, #0284c7);
@ -632,3 +633,346 @@
border-color: var(--info-color, #0284c7);
color: var(--info-light, #e0f2fe);
}
/* =============================================================================
* Expense Import View Styles
* ============================================================================= */
.expenseImportSection {
max-width: 800px;
}
.sectionTitle {
font-size: 1.25rem;
font-weight: 600;
color: var(--text-primary, #1a1a1a);
margin-bottom: 0.5rem;
}
.sectionDescription {
color: var(--text-secondary, #666);
font-size: 0.9375rem;
margin-bottom: 1.5rem;
line-height: 1.5;
position: relative;
}
/* Info Icon and Tooltip */
.infoIcon {
display: inline-block;
margin-left: 0.5rem;
color: var(--info-color, #0284c7);
cursor: pointer;
font-size: 1rem;
vertical-align: middle;
transition: color 0.2s;
}
.infoIcon:hover {
color: var(--primary-color, #3b82f6);
}
.infoTooltip {
position: absolute;
top: calc(100% + 0.5rem);
left: 0;
right: 0;
z-index: 100;
background: var(--info-light, #e0f2fe);
border: 1px solid var(--info-color, #0284c7);
border-radius: 8px;
padding: 1rem;
color: var(--info-color, #0c4a6e);
font-size: 0.875rem;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
animation: fadeIn 0.2s ease-out;
}
.infoTooltip strong {
display: block;
margin-bottom: 0.5rem;
font-size: 0.9375rem;
}
.infoTooltip ul {
margin: 0;
padding-left: 1.25rem;
}
.infoTooltip li {
margin-bottom: 0.375rem;
line-height: 1.5;
}
@keyframes fadeIn {
from {
opacity: 0;
transform: translateY(-4px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
:global(.dark-theme) .infoTooltip {
background: var(--info-dark, #0c4a6e);
border-color: var(--info-color, #0284c7);
color: var(--info-light, #e0f2fe);
}
.setupStep {
display: flex;
gap: 1rem;
padding: 1.25rem;
background: var(--bg-primary, #ffffff);
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 8px;
margin-bottom: 1rem;
}
.stepNumber {
display: flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
min-width: 32px;
background: var(--primary-color, #3b82f6);
color: white;
border-radius: 50%;
font-weight: 600;
font-size: 0.875rem;
}
.stepContent {
flex: 1;
}
.stepContent h4 {
font-size: 1rem;
font-weight: 600;
color: var(--text-primary, #1a1a1a);
margin: 0 0 0.75rem 0;
}
.connectionStatus {
display: flex;
align-items: center;
gap: 0.5rem;
}
.connectedIcon {
display: inline-flex;
align-items: center;
justify-content: center;
width: 24px;
height: 24px;
background: var(--success-light, #dcfce7);
color: var(--success-color, #16a34a);
border-radius: 50%;
font-weight: bold;
font-size: 0.75rem;
}
.connectedText {
color: var(--text-primary, #1a1a1a);
}
.folderSelect {
width: 100%;
max-width: 400px;
padding: 0.625rem 0.875rem;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
font-size: 0.9375rem;
background: var(--bg-primary, #ffffff);
color: var(--text-primary, #1a1a1a);
margin-bottom: 0.75rem;
}
.folderSelect:focus {
outline: none;
border-color: var(--primary-color, #3b82f6);
box-shadow: 0 0 0 3px rgba(59, 130, 246, 0.1);
}
.activateDescription {
color: var(--text-secondary, #666);
font-size: 0.875rem;
margin-bottom: 1rem;
line-height: 1.5;
}
.loadingText {
color: var(--text-secondary, #666);
font-style: italic;
}
.errorMessage {
padding: 0.75rem 1rem;
background: var(--error-light, #fef2f2);
border: 1px solid var(--error-color, #dc2626);
border-radius: 6px;
color: var(--error-color, #dc2626);
font-size: 0.875rem;
margin-bottom: 1rem;
}
.successMessage {
padding: 0.75rem 1rem;
background: var(--success-light, #dcfce7);
border: 1px solid var(--success-color, #16a34a);
border-radius: 6px;
color: var(--success-color, #16a34a);
font-size: 0.875rem;
margin-bottom: 1rem;
}
.infoBox h4 {
font-size: 0.9375rem;
font-weight: 600;
margin: 0 0 0.5rem 0;
}
.infoBox ul {
margin: 0;
padding-left: 1.25rem;
}
.infoBox li {
margin-bottom: 0.375rem;
font-size: 0.875rem;
line-height: 1.5;
}
/* Folder Browser */
.folderBrowser {
margin-top: 0.5rem;
}
.folderList {
max-height: 250px;
overflow-y: auto;
border: 1px solid var(--border-color, #e0e0e0);
border-radius: 6px;
background: var(--bg-primary, #ffffff);
}
.folderItem {
display: flex;
align-items: center;
padding: 0.5rem 0.75rem;
border-bottom: 1px solid var(--border-color, #e0e0e0);
}
.folderItem:last-child {
border-bottom: none;
}
.folderItem:hover {
background: var(--surface-color, #f8f9fa);
}
.folderName {
flex: 1;
color: var(--text-primary, #1a1a1a);
font-size: 0.875rem;
}
.folderName:hover {
color: var(--primary-color, #3b82f6);
}
.selectButton {
padding: 0.25rem 0.5rem;
border: 1px solid var(--primary-color, #3b82f6);
border-radius: 4px;
background: transparent;
color: var(--primary-color, #3b82f6);
font-size: 0.75rem;
cursor: pointer;
}
.selectButton:hover {
background: var(--primary-color, #3b82f6);
color: white;
}
.emptyText {
padding: 1rem;
text-align: center;
color: var(--text-secondary, #666);
font-style: italic;
}
.selectedFolderText {
margin-top: 0.75rem;
padding: 0.5rem;
background: var(--success-light, #dcfce7);
border-radius: 4px;
color: var(--success-color, #16a34a);
font-size: 0.875rem;
}
/* Dark Theme - Folder Browser */
:global(.dark-theme) .folderList {
background: var(--surface-dark, #2a2a2a);
border-color: var(--border-dark, #333);
}
:global(.dark-theme) .folderItem {
border-bottom-color: var(--border-dark, #333);
}
:global(.dark-theme) .folderItem:hover {
background: var(--surface-dark, #333);
}
:global(.dark-theme) .folderName {
color: var(--text-primary-dark, #ffffff);
}
:global(.dark-theme) .selectedFolderText {
background: var(--success-dark, #052e16);
color: var(--success-light, #dcfce7);
}
/* Dark Theme - Expense Import */
:global(.dark-theme) .sectionTitle {
color: var(--text-primary-dark, #ffffff);
}
:global(.dark-theme) .sectionDescription,
:global(.dark-theme) .activateDescription {
color: var(--text-secondary-dark, #aaa);
}
:global(.dark-theme) .setupStep {
background: var(--surface-dark, #1a1a1a);
border-color: var(--border-dark, #333);
}
:global(.dark-theme) .stepContent h4 {
color: var(--text-primary-dark, #ffffff);
}
:global(.dark-theme) .connectedText {
color: var(--text-primary-dark, #ffffff);
}
:global(.dark-theme) .folderSelect {
background: var(--surface-dark, #2a2a2a);
border-color: var(--border-dark, #333);
color: var(--text-primary-dark, #ffffff);
}
:global(.dark-theme) .errorMessage {
background: var(--error-dark, #450a0a);
color: var(--error-light, #fef2f2);
}
:global(.dark-theme) .successMessage {
background: var(--success-dark, #052e16);
color: var(--success-light, #dcfce7);
}

View file

@ -10,3 +10,4 @@ export { TrusteeDocumentsView } from './TrusteeDocumentsView';
export { TrusteePositionsView } from './TrusteePositionsView';
export { TrusteePositionDocumentsView } from './TrusteePositionDocumentsView';
export { TrusteeInstanceRolesView } from './TrusteeInstanceRolesView';
export { TrusteeExpenseImportView } from './TrusteeExpenseImportView';

View file

@ -206,6 +206,7 @@ export const FEATURE_REGISTRY: Record<string, FeatureConfig> = {
{ code: 'positions', label: { de: 'Positionen', en: 'Positions' }, path: 'positions' },
{ code: 'documents', label: { de: 'Dokumente', en: 'Documents' }, path: 'documents' },
{ code: 'position-documents', label: { de: 'Zuordnungen', en: 'Assignments' }, path: 'position-documents' },
{ code: 'expense-import', label: { de: 'Spesen Import', en: 'Expense Import' }, path: 'expense-import' },
{ code: 'instance-roles', label: { de: 'Rollen & Rechte', en: 'Roles & Permissions' }, path: 'instance-roles', adminOnly: true },
]
},