/** * ConnectionsPage * * Page for managing OAuth connections (Google, Microsoft) using FormGeneratorTable. * Follows the pattern established in AdminUsersPage/WorkflowsPage. */ import React, { useState, useMemo, useEffect, useRef } from 'react'; import { useConnections, type Connection } from '../../hooks/useConnections'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; import { FaSync, FaLink, FaRedo, FaPlus, FaSpinner, FaTimes, FaSyncAlt } from 'react-icons/fa'; import styles from '../admin/Admin.module.css'; import bannerStyles from './ConnectionsPage.module.css'; import { AddConnectionWizard } from '../../components/AddConnectionWizard/AddConnectionWizard'; import type { ConnectorType } from '../../components/AddConnectionWizard/AddConnectionWizard'; import type { KnowledgePreferences } from '../../api/connectionApi'; import { useLanguage } from '../../providers/language/LanguageContext'; import { resolveColumnTypes } from '../../utils/columnTypeResolver'; import { getApiBaseUrl } from '../../../config/config'; const SYNC_BANNER_TTL_MS = 10 * 60 * 1000; // 10 minutes — conservative upper bound for bootstrap export const ConnectionsPage: React.FC = () => { const { t } = useLanguage(); // Use the consolidated hook const { data: connections, attributes, permissions, pagination, groupLayout, appliedView, loading, error, refetch, fetchConnectionById, updateOptimistically, fetchGroupSectionSummaries, refetchForSection, deleteConnection, handleInlineUpdate, createConnectionAndAuth, connectWithPopup, refreshMicrosoftToken, refreshGoogleToken, isConnecting, } = useConnections(); const [editingConnection, setEditingConnection] = useState(null); const [deletingConnections, setDeletingConnections] = useState>(new Set()); const [refreshingConnections, setRefreshingConnections] = useState>(new Set()); const [reconnectingConnections, setReconnectingConnections] = useState>(new Set()); const [wizardOpen, setWizardOpen] = useState(false); // Banner shown while knowledge bootstrap is running in the background const [syncBanner, setSyncBanner] = useState<{ connector: string; startedAt: number; } | null>(null); const syncBannerTimer = useRef | null>(null); const showSyncBanner = (connector: string) => { if (syncBannerTimer.current) clearTimeout(syncBannerTimer.current); setSyncBanner({ connector, startedAt: Date.now() }); syncBannerTimer.current = setTimeout(() => setSyncBanner(null), SYNC_BANNER_TTL_MS); }; const dismissSyncBanner = () => { if (syncBannerTimer.current) clearTimeout(syncBannerTimer.current); setSyncBanner(null); }; // Initial fetch useEffect(() => { refetch(); }, []); // Generate columns from attributes - hide internal/redundant fields const columns = useMemo(() => { const hiddenColumns = ['id', 'externalId', 'tokenStatus', 'tokenExpiresAt', 'grantedScopes']; const raw = (attributes || []) .filter(attr => !hiddenColumns.includes(attr.name)) .map(attr => { const col: any = { key: attr.name, label: attr.name === 'userId' ? t('Benutzer') : attr.label || attr.name, sortable: attr.sortable !== false, filterable: attr.filterable !== false, searchable: attr.searchable !== false, width: attr.width || 150, minWidth: attr.minWidth || 100, maxWidth: attr.maxWidth || 400, displayField: (attr as any).displayField, frontendFormat: (attr as any).frontendFormat, frontendFormatLabels: (attr as any).frontendFormatLabels, }; return col; }); return resolveColumnTypes(raw, attributes || []); }, [attributes, t]); // Check permissions const canCreate = permissions?.create !== 'n'; const canUpdate = permissions?.update !== 'n'; const canDelete = permissions?.delete !== 'n'; // Handle edit click const handleEditClick = async (connection: Connection) => { const fullConnection = await fetchConnectionById(connection.id); if (fullConnection) { setEditingConnection(fullConnection as Connection); } }; // Handle edit submit const handleEditSubmit = async (data: Partial) => { if (!editingConnection) return; try { const updateData: Partial = { ...data }; // Strip computed/read-only fields the backend cannot write. delete (updateData as any).connectionReference; delete (updateData as any).displayLabel; delete (updateData as any).tokenStatus; delete (updateData as any).tokenExpiresAt; if (data.authority) { if ( data.authority === 'local' || data.authority === 'google' || data.authority === 'msft' || data.authority === 'clickup' || data.authority === 'infomaniak' ) { updateData.authority = data.authority; } else { delete (updateData as any).authority; } } await handleInlineUpdate(editingConnection.id, updateData, editingConnection); setEditingConnection(null); refetch(); } catch (error) { console.error('Error updating connection:', error); } }; // Handle delete (confirmation handled by DeleteActionButton) const handleDelete = async (connection: Connection) => { setDeletingConnections(prev => new Set(prev).add(connection.id)); try { await deleteConnection(connection.id); refetch(); } catch (error) { console.error('Error deleting connection:', error); } finally { setDeletingConnections(prev => { const newSet = new Set(prev); newSet.delete(connection.id); return newSet; }); } }; // Handle connect const handleConnect = async (connection: Connection) => { try { await connectWithPopup(connection.id); refetch(); } catch (error) { console.error('Error connecting:', error); } }; // Handle refresh token const handleRefresh = async (connection: Connection) => { setRefreshingConnections(prev => new Set(prev).add(connection.id)); try { if (connection.authority === 'msft') { await refreshMicrosoftToken(connection.id); } else if (connection.authority === 'google') { await refreshGoogleToken(connection.id); } refetch(); } catch (error) { console.error('Error refreshing token:', error); } finally { setRefreshingConnections(prev => { const newSet = new Set(prev); newSet.delete(connection.id); return newSet; }); } }; // Handle reconnect (full OAuth re-consent so newly added scopes -- e.g. // Calendar/Contacts -- are actually granted on top of existing tokens). const handleReconnect = async (connection: Connection) => { setReconnectingConnections(prev => new Set(prev).add(connection.id)); try { await connectWithPopup(connection.id, true); refetch(); } catch (error) { console.error('Error reconnecting:', error); } finally { setReconnectingConnections(prev => { const newSet = new Set(prev); newSet.delete(connection.id); return newSet; }); } }; const handleWizardConnect = async ( type: ConnectorType, knowledgeEnabled: boolean, knowledgePreferences?: KnowledgePreferences | null, ) => { try { await createConnectionAndAuth(type, knowledgeEnabled, knowledgePreferences ?? null); refetch(); if (knowledgeEnabled) { const LABELS: Record = { google: 'Google', msft: 'Microsoft 365', clickup: 'ClickUp', infomaniak: 'Infomaniak' }; showSyncBanner(LABELS[type] ?? type); } } catch (error) { console.error('Error creating connection via wizard:', error); } }; const handleMsftAdminConsent = () => { const url = `${getApiBaseUrl()}/api/msft/adminconsent`; window.open(url, 'msft-admin-consent', 'width=560,height=720,scrollbars=yes,resizable=yes'); }; // Form attributes for edit modal const formAttributes = useMemo(() => { const excludedFields = [ 'id', 'mandateId', 'userId', 'sysCreatedBy', 'sysCreatedAt', 'sysModifiedAt', 'connectedAt', 'lastChecked', // computed/read-only fields the backend rejects on write 'connectionReference', 'displayLabel', 'tokenStatus', 'tokenExpiresAt', ]; return (attributes || []) .filter(attr => !excludedFields.includes(attr.name)); }, [attributes]); if (error) { return (
⚠️

{t('Fehler beim Laden der Verbindungen: {detail}', { detail: String(error) })}

); } return (

{t('Verbindungen')}

{t('Persönliche Datenanbindungen verwalten (OAuth: Google, Microsoft, ClickUp, Infomaniak)')}

{canCreate && ( )}
{/* Sync-in-progress banner */} {syncBanner && (
{t('Wissensdatenbank wird synchronisiert')} {t( 'Inhalte aus {connector} werden im Hintergrund indexiert. Das kann je nach Datenmenge einige Minuten dauern. Die Wissensdatenbank steht danach vollständig zur Verfügung.', { connector: syncBanner.connector }, )}
)}
deletingConnections.has(row.id), }] : []), ]} customActions={[ { id: 'connect', icon: , onClick: handleConnect, title: t('Verbinden'), visible: (row: Connection) => row.status !== 'active', loading: () => isConnecting, }, { id: 'refresh', icon: , onClick: handleRefresh, title: t('Token aktualisieren'), visible: (row: Connection) => row.status === 'active' && (row.authority === 'msft' || row.authority === 'google'), loading: (row: Connection) => refreshingConnections.has(row.id), }, { id: 'reconnect', icon: , onClick: handleReconnect, title: t('Erneut verbinden (neue Scopes erteilen)'), visible: (row: Connection) => row.status === 'active' && (row.authority === 'msft' || row.authority === 'google' || row.authority === 'clickup'), loading: (row: Connection) => reconnectingConnections.has(row.id), }, ]} onDelete={handleDelete} hookData={{ refetch, permissions, pagination, groupLayout, appliedView, handleDelete: deleteConnection, handleInlineUpdate, updateOptimistically, fetchGroupSectionSummaries, refetchForSection, }} emptyMessage={t('Keine Verbindungen gefunden')} />
{/* Edit Modal */} {editingConnection && (

{t('Verbindung bearbeiten')}

{formAttributes.length === 0 ? (
{t('Formular laden')}
) : ( setEditingConnection(null)} submitButtonText={t('Speichern')} cancelButtonText={t('Abbrechen')} /> )}
)} setWizardOpen(false)} onConnect={handleWizardConnect} onMsftAdminConsent={handleMsftAdminConsent} isConnecting={isConnecting} />
); }; export default ConnectionsPage;