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:
|
* URL-Struktur:
|
||||||
* - / → Dashboard/Übersicht
|
* - / → Dashboard/Übersicht
|
||||||
* - /settings → Benutzer-Einstellungen
|
* - /settings → Benutzer-Einstellungen
|
||||||
|
* - /gdpr → GDPR / Datenschutz
|
||||||
* - /mandates/:mandateId/:featureCode/:instanceId/* → Feature-Instanz-Routen
|
* - /mandates/:mandateId/:featureCode/:instanceId/* → Feature-Instanz-Routen
|
||||||
* - /admin/* → System-Administration (nur SysAdmin)
|
* - /admin/* → System-Administration (nur SysAdmin)
|
||||||
*/
|
*/
|
||||||
|
|
@ -38,8 +39,9 @@ import { FeatureLayout } from './layouts/FeatureLayout';
|
||||||
// Pages
|
// Pages
|
||||||
import { DashboardPage } from './pages/Dashboard';
|
import { DashboardPage } from './pages/Dashboard';
|
||||||
import { SettingsPage } from './pages/Settings';
|
import { SettingsPage } from './pages/Settings';
|
||||||
|
import { GDPRPage } from './pages/GDPR';
|
||||||
import { FeatureViewPage } from './pages/FeatureView';
|
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)
|
// Workflow Pages (global)
|
||||||
import { PlaygroundPage, WorkflowsPage, AutomationsPage } from './pages/workflows';
|
import { PlaygroundPage, WorkflowsPage, AutomationsPage } from './pages/workflows';
|
||||||
|
|
@ -103,6 +105,7 @@ function App() {
|
||||||
|
|
||||||
{/* System-Seiten (ohne Instanz-Kontext) */}
|
{/* System-Seiten (ohne Instanz-Kontext) */}
|
||||||
<Route path="settings" element={<SettingsPage />} />
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
|
<Route path="gdpr" element={<GDPRPage />} />
|
||||||
|
|
||||||
{/* ============================================== */}
|
{/* ============================================== */}
|
||||||
{/* WORKFLOWS ROUTES (global) */}
|
{/* WORKFLOWS ROUTES (global) */}
|
||||||
|
|
@ -150,6 +153,7 @@ function App() {
|
||||||
<Route path="files" element={<FeatureViewPage view="files" />} />
|
<Route path="files" element={<FeatureViewPage view="files" />} />
|
||||||
<Route path="conversations" element={<FeatureViewPage view="conversations" />} />
|
<Route path="conversations" element={<FeatureViewPage view="conversations" />} />
|
||||||
<Route path="position-documents" element={<FeatureViewPage view="position-documents" />} />
|
<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" />} />
|
<Route path="instance-roles" element={<FeatureViewPage view="instance-roles" />} />
|
||||||
|
|
||||||
{/* Catch-all für unbekannte Sub-Pfade */}
|
{/* Catch-all für unbekannte Sub-Pfade */}
|
||||||
|
|
@ -169,6 +173,7 @@ function App() {
|
||||||
<Route path="invitations" element={<AdminInvitationsPage />} />
|
<Route path="invitations" element={<AdminInvitationsPage />} />
|
||||||
<Route path="mandate-roles" element={<AdminMandateRolesPage />} />
|
<Route path="mandate-roles" element={<AdminMandateRolesPage />} />
|
||||||
<Route path="mandate-role-permissions" element={<AdminMandateRolePermissionsPage />} />
|
<Route path="mandate-role-permissions" element={<AdminMandateRolePermissionsPage />} />
|
||||||
|
<Route path="user-access-overview" element={<AdminUserAccessOverviewPage />} />
|
||||||
</Route>
|
</Route>
|
||||||
</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/local/login') ||
|
||||||
error.config?.url?.includes('/api/msft/login');
|
error.config?.url?.includes('/api/msft/login');
|
||||||
|
|
||||||
// Don't redirect if we're already on the login page (prevents redirect loops)
|
// Don't redirect if we're on a public auth page (prevents redirect loops and allows public pages to work)
|
||||||
const isOnLoginPage = window.location.pathname === '/login' ||
|
const pathname = window.location.pathname;
|
||||||
window.location.pathname.startsWith('/login');
|
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)
|
// Clear local auth data (httpOnly cookies are cleared by backend)
|
||||||
sessionStorage.removeItem('auth_authority');
|
sessionStorage.removeItem('auth_authority');
|
||||||
clearUserDataCache();
|
clearUserDataCache();
|
||||||
|
|
|
||||||
|
|
@ -186,9 +186,13 @@ export function FormGeneratorForm<T extends Record<string, any>>({
|
||||||
filtered = filterFields(filtered);
|
filtered = filterFields(filtered);
|
||||||
} else {
|
} else {
|
||||||
// Default filtering based on mode
|
// 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') {
|
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') {
|
} 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);
|
filtered = filtered.filter(attr => attr.visible !== false && attr.editable !== false);
|
||||||
} else if (mode === 'display') {
|
} else if (mode === 'display') {
|
||||||
filtered = filtered.filter(attr => attr.visible !== false);
|
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));
|
const option = options.find(opt => String(opt.value) === String(v));
|
||||||
return option ? option.label : v;
|
return option ? option.label : v;
|
||||||
}).join(', ') || t('common.none', 'None');
|
}).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 (
|
return (
|
||||||
<div className={styles.floatingLabelInput} key={attr.name}>
|
<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')}
|
{displayValue || t('common.na', 'N/A')}
|
||||||
</div>
|
</div>
|
||||||
<label className={styles.focusedLabel}>
|
<label className={styles.focusedLabel}>
|
||||||
|
|
|
||||||
|
|
@ -35,6 +35,7 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
||||||
// System pages
|
// System pages
|
||||||
'page.system.home': <FaHome />,
|
'page.system.home': <FaHome />,
|
||||||
'page.system.settings': <FaCog />,
|
'page.system.settings': <FaCog />,
|
||||||
|
'page.system.gdpr': <FaShieldAlt />,
|
||||||
'page.system.playground': <FaPlay />,
|
'page.system.playground': <FaPlay />,
|
||||||
'page.system.chats': <FaListAlt />,
|
'page.system.chats': <FaListAlt />,
|
||||||
'page.system.automations': <FaCogs />,
|
'page.system.automations': <FaCogs />,
|
||||||
|
|
@ -55,12 +56,14 @@ export const PAGE_ICONS: Record<string, React.ReactNode> = {
|
||||||
'page.admin.feature-roles': <FaCube />,
|
'page.admin.feature-roles': <FaCube />,
|
||||||
'page.admin.feature-instances': <FaCubes />,
|
'page.admin.feature-instances': <FaCubes />,
|
||||||
'page.admin.feature-users': <FaUsersCog />,
|
'page.admin.feature-users': <FaUsersCog />,
|
||||||
|
'page.admin.user-access-overview': <FaUserShield />,
|
||||||
|
|
||||||
// Feature pages - Trustee
|
// Feature pages - Trustee
|
||||||
'page.feature.trustee.dashboard': <FaChartLine />,
|
'page.feature.trustee.dashboard': <FaChartLine />,
|
||||||
'page.feature.trustee.positions': <FaDatabase />,
|
'page.feature.trustee.positions': <FaDatabase />,
|
||||||
'page.feature.trustee.documents': <FaFileAlt />,
|
'page.feature.trustee.documents': <FaFileAlt />,
|
||||||
'page.feature.trustee.position-documents': <FaLink />,
|
'page.feature.trustee.position-documents': <FaLink />,
|
||||||
|
'page.feature.trustee.expense-import': <FaFileAlt />,
|
||||||
'page.feature.trustee.instance-roles': <FaUserShield />,
|
'page.feature.trustee.instance-roles': <FaUserShield />,
|
||||||
|
|
||||||
// Feature pages - Real Estate
|
// Feature pages - Real Estate
|
||||||
|
|
|
||||||
|
|
@ -7,9 +7,9 @@ import { useAutomations, useAutomationOperations } from '../../../../hooks/useAu
|
||||||
const attributesToColumns = (attributes: any[]) => {
|
const attributesToColumns = (attributes: any[]) => {
|
||||||
return attributes
|
return attributes
|
||||||
.filter(attr => {
|
.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 attrNameLower = attr.name.toLowerCase();
|
||||||
const excludedColumns = ['template', 'executionlogs', 'execution_logs'];
|
const excludedColumns = ['template', 'placeholders', 'executionlogs', 'execution_logs'];
|
||||||
return !excludedColumns.includes(attrNameLower);
|
return !excludedColumns.includes(attrNameLower);
|
||||||
})
|
})
|
||||||
.map(attr => {
|
.map(attr => {
|
||||||
|
|
|
||||||
|
|
@ -62,14 +62,6 @@ export interface InvitationValidation {
|
||||||
roleLabels?: string[];
|
roleLabels?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface RegisterAndAcceptData {
|
|
||||||
token: string;
|
|
||||||
username: string;
|
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
firstname?: string;
|
|
||||||
lastname?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Hook for managing invitations
|
* 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 {
|
return {
|
||||||
invitations,
|
invitations,
|
||||||
loading,
|
loading,
|
||||||
|
|
@ -276,7 +248,6 @@ export function useInvitations() {
|
||||||
revokeInvitation,
|
revokeInvitation,
|
||||||
validateInvitation,
|
validateInvitation,
|
||||||
acceptInvitation,
|
acceptInvitation,
|
||||||
registerAndAccept,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@ import { TrusteePositionsView } from './views/trustee/TrusteePositionsView';
|
||||||
import { TrusteePositionDocumentsView } from './views/trustee/TrusteePositionDocumentsView';
|
import { TrusteePositionDocumentsView } from './views/trustee/TrusteePositionDocumentsView';
|
||||||
import { TrusteeDashboardView } from './views/trustee/TrusteeDashboardView';
|
import { TrusteeDashboardView } from './views/trustee/TrusteeDashboardView';
|
||||||
import { TrusteeInstanceRolesView } from './views/trustee/TrusteeInstanceRolesView';
|
import { TrusteeInstanceRolesView } from './views/trustee/TrusteeInstanceRolesView';
|
||||||
|
import { TrusteeExpenseImportView } from './views/trustee/TrusteeExpenseImportView';
|
||||||
|
|
||||||
import styles from './FeatureView.module.css';
|
import styles from './FeatureView.module.css';
|
||||||
|
|
||||||
|
|
@ -81,6 +82,7 @@ const VIEW_COMPONENTS: Record<string, Record<string, ViewComponent>> = {
|
||||||
positions: TrusteePositionsView,
|
positions: TrusteePositionsView,
|
||||||
'position-documents': TrusteePositionDocumentsView,
|
'position-documents': TrusteePositionDocumentsView,
|
||||||
'instance-roles': TrusteeInstanceRolesView,
|
'instance-roles': TrusteeInstanceRolesView,
|
||||||
|
'expense-import': TrusteeExpenseImportView,
|
||||||
},
|
},
|
||||||
chatworkflow: {
|
chatworkflow: {
|
||||||
dashboard: ChatworkflowDashboard,
|
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;
|
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 */
|
/* Responsive */
|
||||||
@media (max-width: 500px) {
|
@media (max-width: 500px) {
|
||||||
.card {
|
.card {
|
||||||
|
|
|
||||||
|
|
@ -4,22 +4,26 @@
|
||||||
* Public page for accepting invitations.
|
* Public page for accepting invitations.
|
||||||
* URL: /invite/:token
|
* URL: /invite/:token
|
||||||
*
|
*
|
||||||
* Handles both:
|
* Flow:
|
||||||
* - Existing users (shows login or auto-accepts if already logged in)
|
* - Validates the invitation token
|
||||||
* - New users (shows registration form)
|
* - 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 React, { useState, useEffect } from 'react';
|
||||||
import { useParams, useNavigate, Link } from 'react-router-dom';
|
import { useParams, useNavigate, Link } from 'react-router-dom';
|
||||||
import { useInvitations, type InvitationValidation, type RegisterAndAcceptData } from '../hooks/useInvitations';
|
import { useInvitations, type InvitationValidation } from '../hooks/useInvitations';
|
||||||
// Note: useAuth not needed for InvitePage
|
import { FaCheckCircle, FaTimesCircle, FaSpinner, FaSignInAlt, FaUserPlus } from 'react-icons/fa';
|
||||||
import { FaCheckCircle, FaTimesCircle, FaSpinner, FaEnvelope, FaUser, FaLock } from 'react-icons/fa';
|
|
||||||
import styles from './InvitePage.module.css';
|
import styles from './InvitePage.module.css';
|
||||||
|
|
||||||
|
// Key for storing pending invitation token
|
||||||
|
export const PENDING_INVITATION_KEY = 'pendingInvitationToken';
|
||||||
|
|
||||||
export const InvitePage: React.FC = () => {
|
export const InvitePage: React.FC = () => {
|
||||||
const { token } = useParams<{ token: string }>();
|
const { token } = useParams<{ token: string }>();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { validateInvitation, acceptInvitation, registerAndAccept } = useInvitations();
|
const { validateInvitation, acceptInvitation } = useInvitations();
|
||||||
|
|
||||||
// Check if user has auth token (simplified check)
|
// Check if user has auth token (simplified check)
|
||||||
const isAuthenticated = !!sessionStorage.getItem('auth_authority');
|
const isAuthenticated = !!sessionStorage.getItem('auth_authority');
|
||||||
|
|
@ -31,17 +35,6 @@ export const InvitePage: React.FC = () => {
|
||||||
const [success, setSuccess] = useState(false);
|
const [success, setSuccess] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
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
|
// Validate token on mount
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const validate = async () => {
|
const validate = async () => {
|
||||||
|
|
@ -55,14 +48,17 @@ export const InvitePage: React.FC = () => {
|
||||||
setValidation(result);
|
setValidation(result);
|
||||||
setValidating(false);
|
setValidating(false);
|
||||||
|
|
||||||
// Update form with token
|
// If invitation is valid but user is not authenticated,
|
||||||
setFormData(prev => ({ ...prev, token }));
|
// store the token for later use after login/registration
|
||||||
|
if (result.valid && !isAuthenticated) {
|
||||||
|
sessionStorage.setItem(PENDING_INVITATION_KEY, token);
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
validate();
|
validate();
|
||||||
}, [token, validateInvitation]);
|
}, [token, validateInvitation, isAuthenticated]);
|
||||||
|
|
||||||
// Auto-accept if already logged in
|
// Accept invitation (for authenticated users)
|
||||||
const handleAccept = async () => {
|
const handleAccept = async () => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
|
|
@ -72,6 +68,8 @@ export const InvitePage: React.FC = () => {
|
||||||
const result = await acceptInvitation(token);
|
const result = await acceptInvitation(token);
|
||||||
|
|
||||||
if (result.success) {
|
if (result.success) {
|
||||||
|
// Clear pending invitation token
|
||||||
|
sessionStorage.removeItem(PENDING_INVITATION_KEY);
|
||||||
setSuccess(true);
|
setSuccess(true);
|
||||||
// Redirect to dashboard after 2 seconds
|
// Redirect to dashboard after 2 seconds
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
|
|
@ -84,44 +82,20 @@ export const InvitePage: React.FC = () => {
|
||||||
setAccepting(false);
|
setAccepting(false);
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle registration form submission
|
// Handle redirect to login (stores token first)
|
||||||
const handleRegister = async (e: React.FormEvent) => {
|
const handleLoginRedirect = () => {
|
||||||
e.preventDefault();
|
if (token) {
|
||||||
setError(null);
|
sessionStorage.setItem(PENDING_INVITATION_KEY, token);
|
||||||
|
|
||||||
// Validate passwords match
|
|
||||||
if (formData.password !== confirmPassword) {
|
|
||||||
setError('Die Passwörter stimmen nicht überein');
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
|
navigate('/login', { state: { from: { pathname: `/invite/${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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle form field changes
|
// Handle redirect to register (stores token first)
|
||||||
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
const handleRegisterRedirect = () => {
|
||||||
const { name, value } = e.target;
|
if (token) {
|
||||||
setFormData(prev => ({ ...prev, [name]: value }));
|
sessionStorage.setItem(PENDING_INVITATION_KEY, token);
|
||||||
|
}
|
||||||
|
navigate('/register', { state: { from: { pathname: `/invite/${token}` }, pendingInvitation: true } });
|
||||||
};
|
};
|
||||||
|
|
||||||
// Loading state
|
// Loading state
|
||||||
|
|
@ -164,11 +138,8 @@ export const InvitePage: React.FC = () => {
|
||||||
<div className={styles.successState}>
|
<div className={styles.successState}>
|
||||||
<FaCheckCircle className={styles.successIcon} />
|
<FaCheckCircle className={styles.successIcon} />
|
||||||
<h1>Erfolgreich!</h1>
|
<h1>Erfolgreich!</h1>
|
||||||
<p>
|
<p>Sie wurden erfolgreich zum Mandanten hinzugefügt.</p>
|
||||||
{isAuthenticated
|
<p className={styles.redirectMessage}>Sie werden weitergeleitet...</p>
|
||||||
? 'Sie wurden erfolgreich zum Mandanten hinzugefügt.'
|
|
||||||
: 'Ihr Konto wurde erstellt. Sie werden zur Anmeldeseite weitergeleitet...'}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -186,6 +157,12 @@ export const InvitePage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.inviteInfo}>
|
<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}>
|
<div className={styles.infoRow}>
|
||||||
<span className={styles.infoLabel}>Status:</span>
|
<span className={styles.infoLabel}>Status:</span>
|
||||||
<span className={styles.infoValue}>Angemeldet</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 (
|
return (
|
||||||
<div className={styles.container}>
|
<div className={styles.container}>
|
||||||
<div className={styles.card}>
|
<div className={styles.card}>
|
||||||
<div className={styles.header}>
|
<div className={styles.header}>
|
||||||
<h1>Einladung annehmen</h1>
|
<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>
|
</div>
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
|
|
@ -242,120 +238,31 @@ export const InvitePage: React.FC = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<form onSubmit={handleRegister} className={styles.form}>
|
<div className={styles.authActions}>
|
||||||
<div className={styles.formRow}>
|
<button
|
||||||
<div className={styles.formGroup}>
|
className={styles.primaryButton}
|
||||||
<label htmlFor="firstname">Vorname</label>
|
onClick={handleLoginRedirect}
|
||||||
<input
|
>
|
||||||
type="text"
|
<FaSignInAlt /> Anmelden
|
||||||
id="firstname"
|
</button>
|
||||||
name="firstname"
|
|
||||||
value={formData.firstname}
|
<div className={styles.divider}>
|
||||||
onChange={handleChange}
|
<span>oder</span>
|
||||||
placeholder="Max"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className={styles.formGroup}>
|
|
||||||
<label htmlFor="lastname">Nachname</label>
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
id="lastname"
|
|
||||||
name="lastname"
|
|
||||||
value={formData.lastname}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="Mustermann"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.formGroup}>
|
<button
|
||||||
<label htmlFor="username">
|
className={styles.secondaryButton}
|
||||||
<FaUser /> Benutzername *
|
onClick={handleRegisterRedirect}
|
||||||
</label>
|
>
|
||||||
<input
|
<FaUserPlus /> Neues Konto erstellen
|
||||||
type="text"
|
</button>
|
||||||
id="username"
|
|
||||||
name="username"
|
|
||||||
value={formData.username}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="maxmustermann"
|
|
||||||
required
|
|
||||||
minLength={3}
|
|
||||||
maxLength={50}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.formGroup}>
|
|
||||||
<label htmlFor="email">
|
|
||||||
<FaEnvelope /> E-Mail *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="email"
|
|
||||||
id="email"
|
|
||||||
name="email"
|
|
||||||
value={formData.email}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="max@example.com"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.formGroup}>
|
|
||||||
<label htmlFor="password">
|
|
||||||
<FaLock /> Passwort * (min. 8 Zeichen)
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
id="password"
|
|
||||||
name="password"
|
|
||||||
value={formData.password}
|
|
||||||
onChange={handleChange}
|
|
||||||
placeholder="••••••••"
|
|
||||||
required
|
|
||||||
minLength={8}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.formGroup}>
|
|
||||||
<label htmlFor="confirmPassword">
|
|
||||||
<FaLock /> Passwort bestätigen *
|
|
||||||
</label>
|
|
||||||
<input
|
|
||||||
type="password"
|
|
||||||
id="confirmPassword"
|
|
||||||
value={confirmPassword}
|
|
||||||
onChange={(e) => setConfirmPassword(e.target.value)}
|
|
||||||
placeholder="••••••••"
|
|
||||||
required
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={styles.actions}>
|
|
||||||
<button
|
|
||||||
type="submit"
|
|
||||||
className={styles.primaryButton}
|
|
||||||
disabled={accepting}
|
|
||||||
>
|
|
||||||
{accepting ? (
|
|
||||||
<>
|
|
||||||
<FaSpinner className={styles.spinner} /> Wird verarbeitet...
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
'Konto erstellen & Einladung annehmen'
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<div className={styles.divider}>
|
|
||||||
<span>oder</span>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className={styles.loginOption}>
|
<div className={styles.authInfo}>
|
||||||
<p>Sie haben bereits ein Konto?</p>
|
<p>
|
||||||
<Link to={`/login?redirect=/invite/${token}`} className={styles.secondaryButton}>
|
Sie können sich mit Ihrem bestehenden Konto anmelden oder ein neues erstellen.
|
||||||
Anmelden und Einladung annehmen
|
Die Einladung wird automatisch nach der Anmeldung akzeptiert.
|
||||||
</Link>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -264,6 +264,25 @@ button:disabled {
|
||||||
font-family: var(--font-family);
|
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 {
|
.passwordResetLink {
|
||||||
display: flex;
|
display: flex;
|
||||||
justify-content: center;
|
justify-content: center;
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,10 @@
|
||||||
import { useNavigate, useLocation } from 'react-router-dom';
|
import { useNavigate, useLocation } from 'react-router-dom';
|
||||||
import { useState, useEffect } from 'react';
|
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 { useAuth, useMsalAuth, useGoogleAuth } from '../hooks/useAuthentication';
|
||||||
import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
|
import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
|
||||||
|
import { PENDING_INVITATION_KEY } from './InvitePage';
|
||||||
|
|
||||||
import styles from './Login.module.css';
|
import styles from './Login.module.css';
|
||||||
|
|
||||||
|
|
@ -19,6 +20,10 @@ function Login() {
|
||||||
const { loginWithMsal, error: msalError, isLoading: isMsalLoading } = useMsalAuth();
|
const { loginWithMsal, error: msalError, isLoading: isMsalLoading } = useMsalAuth();
|
||||||
const { loginWithGoogle, error: googleError, isLoading: isGoogleLoading } = useGoogleAuth();
|
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
|
// Get the page the user was trying to visit
|
||||||
const from = location.state?.from?.pathname || "/";
|
const from = location.state?.from?.pathname || "/";
|
||||||
|
|
||||||
|
|
@ -51,12 +56,22 @@ function Login() {
|
||||||
return () => clearTimeout(timer);
|
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 () => {
|
const handleMsalLogin = async () => {
|
||||||
try {
|
try {
|
||||||
console.log("Attempting MSAL login...");
|
console.log("Attempting MSAL login...");
|
||||||
const response = await loginWithMsal();
|
const response = await loginWithMsal();
|
||||||
console.log("MSAL login successful:", response);
|
console.log("MSAL login successful:", response);
|
||||||
navigate(from, { replace: true });
|
handleSuccessfulLogin();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("MSAL login failed:", error);
|
console.error("MSAL login failed:", error);
|
||||||
}
|
}
|
||||||
|
|
@ -67,7 +82,7 @@ function Login() {
|
||||||
console.log("Attempting Google login...");
|
console.log("Attempting Google login...");
|
||||||
const response = await loginWithGoogle();
|
const response = await loginWithGoogle();
|
||||||
console.log("Google login successful:", response);
|
console.log("Google login successful:", response);
|
||||||
navigate(from, { replace: true });
|
handleSuccessfulLogin();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Google login failed:", error);
|
console.error("Google login failed:", error);
|
||||||
}
|
}
|
||||||
|
|
@ -78,9 +93,8 @@ function Login() {
|
||||||
try {
|
try {
|
||||||
console.log("Attempting login with:", username);
|
console.log("Attempting login with:", username);
|
||||||
await login(username, password);
|
await login(username, password);
|
||||||
console.log("Login successful, navigating to:", from);
|
console.log("Login successful");
|
||||||
// Only navigate if login was successful
|
handleSuccessfulLogin();
|
||||||
navigate(from, { replace: true });
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Login failed:", error);
|
console.error("Login failed:", error);
|
||||||
// Stay on login page to show error message
|
// Stay on login page to show error message
|
||||||
|
|
@ -100,6 +114,14 @@ function Login() {
|
||||||
<div className={styles.loginSection}>
|
<div className={styles.loginSection}>
|
||||||
<div className={styles.loginBox}>
|
<div className={styles.loginBox}>
|
||||||
<div className={styles.loginForm}>
|
<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) && (
|
{(loginError || msalError || googleError) && (
|
||||||
<div className={styles.error}>{loginError || msalError || googleError}</div>
|
<div className={styles.error}>{loginError || msalError || googleError}</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -191,7 +213,7 @@ function Login() {
|
||||||
<span>Du hast noch keinen Konto?</span>
|
<span>Du hast noch keinen Konto?</span>
|
||||||
<button
|
<button
|
||||||
className={styles.textButton}
|
className={styles.textButton}
|
||||||
onClick={() => navigate("/register")}
|
onClick={() => navigate("/register", { state: location.state })}
|
||||||
>
|
>
|
||||||
Registrieren
|
Registrieren
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -240,6 +240,25 @@ button:disabled {
|
||||||
margin-bottom: 10px;
|
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 {
|
.infoMessage {
|
||||||
background-color: rgba(59, 130, 246, 0.1);
|
background-color: rgba(59, 130, 246, 0.1);
|
||||||
border: 1px solid rgba(59, 130, 246, 0.3);
|
border: 1px solid rgba(59, 130, 246, 0.3);
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
import { useState, useEffect } from 'react';
|
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 styles from './Register.module.css';
|
||||||
import { useRegister, useMsalRegister, useUsernameAvailability } from '../hooks/useAuthentication';
|
import { useRegister, useMsalRegister, useUsernameAvailability } from '../hooks/useAuthentication';
|
||||||
import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
|
import { generateAndStoreCSRFToken } from '../utils/csrfUtils';
|
||||||
|
import { PENDING_INVITATION_KEY } from './InvitePage';
|
||||||
|
|
||||||
interface RegisterFormData {
|
interface RegisterFormData {
|
||||||
username: string;
|
username: string;
|
||||||
|
|
@ -13,6 +15,7 @@ interface RegisterFormData {
|
||||||
|
|
||||||
function Register() {
|
function Register() {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
const location = useLocation();
|
||||||
const { register, error: registerError, isLoading } = useRegister();
|
const { register, error: registerError, isLoading } = useRegister();
|
||||||
const { error: msalError } = useMsalRegister();
|
const { error: msalError } = useMsalRegister();
|
||||||
const { checkAvailability, isChecking, error: availabilityError } = useUsernameAvailability();
|
const { checkAvailability, isChecking, error: availabilityError } = useUsernameAvailability();
|
||||||
|
|
@ -28,6 +31,10 @@ function Register() {
|
||||||
const [fullNameFocused, setFullNameFocused] = useState(false);
|
const [fullNameFocused, setFullNameFocused] = useState(false);
|
||||||
const [usernameHighlight, setUsernameHighlight] = 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
|
// Set page title and generate CSRF token
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
document.title = "PowerOn AI Platform - Registrieren";
|
document.title = "PowerOn AI Platform - Registrieren";
|
||||||
|
|
@ -89,15 +96,23 @@ function Register() {
|
||||||
// Username is available, proceed with registration (no password - magic link flow)
|
// Username is available, proceed with registration (no password - magic link flow)
|
||||||
await register(formData);
|
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
|
// 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
|
// Redirect to login page after delay
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
navigate('/login', {
|
navigate('/login', {
|
||||||
state: {
|
state: {
|
||||||
registered: true,
|
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);
|
}, 5000);
|
||||||
|
|
@ -127,6 +142,14 @@ function Register() {
|
||||||
<div className={styles.loginSection}>
|
<div className={styles.loginSection}>
|
||||||
<div className={styles.loginBox}>
|
<div className={styles.loginBox}>
|
||||||
<div className={styles.loginForm}>
|
<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() && (
|
{getErrorMessage() && (
|
||||||
<div className={styles.error}>{getErrorMessage()}</div>
|
<div className={styles.error}>{getErrorMessage()}</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -203,7 +226,7 @@ function Register() {
|
||||||
<span>Bereits registriert?</span>
|
<span>Bereits registriert?</span>
|
||||||
<button
|
<button
|
||||||
className={styles.textButton}
|
className={styles.textButton}
|
||||||
onClick={() => navigate("/login")}
|
onClick={() => navigate("/login", { state: location.state })}
|
||||||
>
|
>
|
||||||
Jetzt anmelden
|
Jetzt anmelden
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -154,6 +154,13 @@
|
||||||
transition: all 0.2s ease;
|
transition: all 0.2s ease;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.linkButton {
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
.button:hover {
|
.button:hover {
|
||||||
background: var(--surface-color, #f5f5f5);
|
background: var(--surface-color, #f5f5f5);
|
||||||
border-color: var(--border-color, #c0c0c0);
|
border-color: var(--border-color, #c0c0c0);
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
*/
|
*/
|
||||||
|
|
||||||
import React, { useState, useCallback } from 'react';
|
import React, { useState, useCallback } from 'react';
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
import { useLanguage } from '../providers/language/LanguageContext';
|
import { useLanguage } from '../providers/language/LanguageContext';
|
||||||
import { useCurrentUser, useUser } from '../hooks/useUsers';
|
import { useCurrentUser, useUser } from '../hooks/useUsers';
|
||||||
import { setUserDataCache, getUserDataCache } from '../utils/userCache';
|
import { setUserDataCache, getUserDataCache } from '../utils/userCache';
|
||||||
|
|
@ -320,6 +321,25 @@ export const SettingsPage: React.FC = () => {
|
||||||
)}
|
)}
|
||||||
</section>
|
</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 */}
|
{/* Info */}
|
||||||
<section className={styles.section}>
|
<section className={styles.section}>
|
||||||
<h2 className={styles.sectionTitle}>Über</h2>
|
<h2 className={styles.sectionTitle}>Über</h2>
|
||||||
|
|
|
||||||
|
|
@ -179,15 +179,15 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle revoke invitation
|
// Handle delete invitation by ID (for DeleteActionButton)
|
||||||
const handleRevokeInvitation = async (invitation: Invitation) => {
|
// Note: DeleteActionButton handles confirmation UI, so no window.confirm here
|
||||||
if (!selectedMandateId) return;
|
const handleDeleteInvitation = async (invitationId: string): Promise<boolean> => {
|
||||||
if (window.confirm('Möchten Sie diese Einladung wirklich widerrufen?')) {
|
if (!selectedMandateId) return false;
|
||||||
const result = await revokeInvitation(selectedMandateId, invitation.id);
|
const result = await revokeInvitation(selectedMandateId, invitationId);
|
||||||
if (!result.success) {
|
if (!result.success) {
|
||||||
alert(result.error || 'Fehler beim Widerrufen der Einladung');
|
alert(result.error || 'Fehler beim Widerrufen der Einladung');
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return result.success;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Handle show URL
|
// Handle show URL
|
||||||
|
|
@ -352,9 +352,9 @@ export const AdminInvitationsPage: React.FC = () => {
|
||||||
title: 'Einladungs-Link anzeigen',
|
title: 'Einladungs-Link anzeigen',
|
||||||
}
|
}
|
||||||
]}
|
]}
|
||||||
onDelete={handleRevokeInvitation}
|
|
||||||
hookData={{
|
hookData={{
|
||||||
refetch: fetchInvitations,
|
handleDelete: handleDeleteInvitation,
|
||||||
|
refetch: () => fetchInvitations(selectedMandateId, { includeExpired: showExpired, includeUsed: showUsed }),
|
||||||
pagination,
|
pagination,
|
||||||
}}
|
}}
|
||||||
emptyMessage="Keine Einladungen gefunden"
|
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;
|
||||||
|
|
@ -13,3 +13,4 @@ export { AdminMandateRolesPage } from './AdminMandateRolesPage';
|
||||||
export { AdminFeatureRolesPage } from './AdminFeatureRolesPage';
|
export { AdminFeatureRolesPage } from './AdminFeatureRolesPage';
|
||||||
export { AdminFeatureInstanceUsersPage } from './AdminFeatureInstanceUsersPage';
|
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 {
|
.infoBox {
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
padding: 0.75rem 1rem;
|
padding: 0.75rem 1rem;
|
||||||
background: var(--info-light, #e0f2fe);
|
background: var(--info-light, #e0f2fe);
|
||||||
border: 1px solid var(--info-color, #0284c7);
|
border: 1px solid var(--info-color, #0284c7);
|
||||||
|
|
@ -632,3 +633,346 @@
|
||||||
border-color: var(--info-color, #0284c7);
|
border-color: var(--info-color, #0284c7);
|
||||||
color: var(--info-light, #e0f2fe);
|
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 { TrusteePositionsView } from './TrusteePositionsView';
|
||||||
export { TrusteePositionDocumentsView } from './TrusteePositionDocumentsView';
|
export { TrusteePositionDocumentsView } from './TrusteePositionDocumentsView';
|
||||||
export { TrusteeInstanceRolesView } from './TrusteeInstanceRolesView';
|
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: 'positions', label: { de: 'Positionen', en: 'Positions' }, path: 'positions' },
|
||||||
{ code: 'documents', label: { de: 'Dokumente', en: 'Documents' }, path: 'documents' },
|
{ code: 'documents', label: { de: 'Dokumente', en: 'Documents' }, path: 'documents' },
|
||||||
{ code: 'position-documents', label: { de: 'Zuordnungen', en: 'Assignments' }, path: 'position-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 },
|
{ code: 'instance-roles', label: { de: 'Rollen & Rechte', en: 'Roles & Permissions' }, path: 'instance-roles', adminOnly: true },
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue