gpdr compliancy implemented
This commit is contained in:
parent
bf4ddc6fd5
commit
2b220fe816
24 changed files with 2541 additions and 237 deletions
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
16
src/api.ts
16
src/api.ts
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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}>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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
251
src/pages/GDPR.module.css
Normal 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
334
src/pages/GDPR.tsx
Normal 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;
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
658
src/pages/admin/AdminUserAccessOverviewPage.tsx
Normal file
658
src/pages/admin/AdminUserAccessOverviewPage.tsx
Normal 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;
|
||||
|
|
@ -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';
|
||||
658
src/pages/views/trustee/TrusteeExpenseImportView.tsx
Normal file
658
src/pages/views/trustee/TrusteeExpenseImportView.tsx
Normal 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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,3 +10,4 @@ export { TrusteeDocumentsView } from './TrusteeDocumentsView';
|
|||
export { TrusteePositionsView } from './TrusteePositionsView';
|
||||
export { TrusteePositionDocumentsView } from './TrusteePositionDocumentsView';
|
||||
export { TrusteeInstanceRolesView } from './TrusteeInstanceRolesView';
|
||||
export { TrusteeExpenseImportView } from './TrusteeExpenseImportView';
|
||||
|
|
|
|||
|
|
@ -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 },
|
||||
]
|
||||
},
|
||||
|
|
|
|||
Loading…
Reference in a new issue