/** * ConnectionsPage * * Page for managing OAuth connections (Google, Microsoft) using FormGeneratorTable. * Follows the pattern established in AdminUsersPage/WorkflowsPage. */ import React, { useState, useMemo, useEffect } from 'react'; import { useConnections, type Connection } from '../../hooks/useConnections'; import { FormGeneratorTable } from '../../components/FormGenerator/FormGeneratorTable'; import { FormGeneratorForm } from '../../components/FormGenerator/FormGeneratorForm'; import { FaSync, FaGoogle, FaMicrosoft, FaLink, FaRedo, FaShieldAlt, FaTasks, FaCloud, FaSyncAlt } from 'react-icons/fa'; import { getApiBaseUrl } from '../../../config/config'; import styles from '../admin/Admin.module.css'; import { useLanguage } from '../../providers/language/LanguageContext'; import { resolveColumnTypes } from '../../utils/columnTypeResolver'; export const ConnectionsPage: React.FC = () => { const { t } = useLanguage(); // Use the consolidated hook const { data: connections, attributes, permissions, pagination, loading, error, refetch, fetchConnectionById, updateOptimistically, deleteConnection, handleInlineUpdate, createGoogleConnectionAndAuth, createMicrosoftConnectionAndAuth, createClickupConnectionAndAuth, createInfomaniakConnection, submitInfomaniakToken, 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 [adminConsentPending, setAdminConsentPending] = useState(false); // Infomaniak PAT modal: holds the pending connectionId (created up-front so the // user only commits if they actually paste a valid token; on cancel we delete it). const [infomaniakModal, setInfomaniakModal] = useState<{ connectionId: string; token: string; submitting: boolean; error: string | null; } | null>(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; }); } }; // Guards prevent double-trigger while the OAuth popup is open, which would // otherwise create additional orphan PENDING connections on every click. const handleCreateGoogle = async () => { if (isConnecting) return; try { await createGoogleConnectionAndAuth(); refetch(); } catch (error) { console.error('Error creating Google connection:', error); } }; const handleCreateMicrosoft = async () => { if (isConnecting) return; try { await createMicrosoftConnectionAndAuth(); refetch(); } catch (error) { console.error('Error creating Microsoft connection:', error); } }; const handleCreateClickup = async () => { if (isConnecting) return; try { await createClickupConnectionAndAuth(); refetch(); } catch (error) { console.error('Error creating ClickUp connection:', error); } }; const handleCreateInfomaniak = async () => { if (isConnecting || infomaniakModal) return; try { const newConnection = await createInfomaniakConnection(); setInfomaniakModal({ connectionId: newConnection.id, token: '', submitting: false, error: null, }); refetch(); } catch (error) { console.error('Error creating Infomaniak connection:', error); } }; const handleInfomaniakCancel = async () => { if (!infomaniakModal) return; const { connectionId, submitting } = infomaniakModal; if (submitting) return; setInfomaniakModal(null); try { await deleteConnection(connectionId); refetch(); } catch (error) { console.error('Error rolling back pending Infomaniak connection:', error); } }; const handleInfomaniakSubmit = async () => { if (!infomaniakModal) return; const trimmed = infomaniakModal.token.trim(); if (!trimmed) { setInfomaniakModal({ ...infomaniakModal, error: t('Bitte Personal Access Token einfügen') }); return; } setInfomaniakModal({ ...infomaniakModal, submitting: true, error: null }); try { await submitInfomaniakToken(infomaniakModal.connectionId, trimmed); setInfomaniakModal(null); refetch(); } catch (error: any) { const detail = error?.response?.data?.detail || error?.message || t('Token konnte nicht gespeichert werden'); setInfomaniakModal((prev) => prev ? { ...prev, submitting: false, error: String(detail) } : prev ); } }; // Open Microsoft Admin Consent flow in a popup const handleAdminConsent = () => { setAdminConsentPending(true); const consentUrl = `${getApiBaseUrl()}/api/msft/adminconsent`; const popup = window.open(consentUrl, 'msft-admin-consent', 'width=600,height=700,scrollbars=yes,resizable=yes'); if (!popup) { setAdminConsentPending(false); return; } const checkClosed = setInterval(() => { if (popup.closed) { clearInterval(checkClosed); setAdminConsentPending(false); refetch(); } }, 1000); }; // 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 && ( <> )}
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, handleDelete: deleteConnection, handleInlineUpdate, updateOptimistically, }} 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')} /> )}
)} {/* Infomaniak Personal Access Token Modal */} {infomaniakModal && (

{t('Infomaniak verbinden')}

{t( 'Infomaniak nutzt für kDrive und kSuite keine OAuth-Anmeldung, sondern ein persönliches API-Token (PAT). Erstelle das Token einmalig im Infomaniak Manager und füge es unten ein.' )}

  1. {t('Öffne den Infomaniak-Manager:')}{' '} manager.infomaniak.com – API-Tokens
  2. {t('Klicke auf')} {t('Token erstellen')}{' '} {t('und vergib einen aussagekräftigen Namen, z. B.')}{' '} PowerOn.{' '} {t('Application bleibt auf')} Default application.
  3. {t('Suche im Scope-Feld nach')}{' '} {t('allen vier')}{' '} {t('Berechtigungen und kreuze sie an:')}
    • drive — {t('kDrive (Pflicht, heute aktiv)')}
    • workspace:calendar —{' '} {t('Kalender (Pflicht, heute aktiv)')}
    • workspace:contact —{' '} {t('Kontakte (heute aktiv)')}
    • workspace:mail —{' '} {t('Mail (in Vorbereitung, Scope schon mitnehmen)')}
    {t( 'Nicht "All" auswählen und nicht user_info / accounts — wird nicht benötigt.' )}
  4. {t( 'Kopiere das Token sofort (Infomaniak zeigt es nur einmal) und füge es hier ein:' )}
setInfomaniakModal((prev) => prev ? { ...prev, token: e.target.value, error: null } : prev ) } placeholder={t('Personal Access Token einfügen')} disabled={infomaniakModal.submitting} autoFocus style={{ width: '100%', padding: '8px 10px', fontFamily: 'monospace', fontSize: 13, border: '1px solid var(--border, #ccc)', borderRadius: 4, marginBottom: 12, }} /> {infomaniakModal.error && (
{infomaniakModal.error}
)}

{t( 'Das Token wird verschlüsselt gespeichert und nur für API-Aufrufe an Infomaniak verwendet.' )}

)}
); }; export default ConnectionsPage;