ui-nyla/src/pages/basedata/ConnectionsPage.tsx
2026-05-17 00:07:54 +02:00

437 lines
15 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<Connection | null>(null);
const [deletingConnections, setDeletingConnections] = useState<Set<string>>(new Set());
const [refreshingConnections, setRefreshingConnections] = useState<Set<string>>(new Set());
const [reconnectingConnections, setReconnectingConnections] = useState<Set<string>>(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<ReturnType<typeof setTimeout> | 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<Connection>) => {
if (!editingConnection) return;
try {
const updateData: Partial<import('../../api/connectionApi').Connection> = { ...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<ConnectorType, string> = { 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 (
<div className={styles.adminPage}>
<div className={styles.errorContainer}>
<span className={styles.errorIcon}></span>
<p className={styles.errorMessage}>{t('Fehler beim Laden der Verbindungen: {detail}', { detail: String(error) })}</p>
<button className={styles.secondaryButton} onClick={() => refetch()}>
<FaSync /> {t('Erneut versuchen')}
</button>
</div>
</div>
);
}
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>{t('Verbindungen')}</h1>
<p className={styles.pageSubtitle}>
{t('Persönliche Datenanbindungen verwalten (OAuth: Google, Microsoft, ClickUp, Infomaniak)')}
</p>
</div>
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => refetch()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
{canCreate && (
<button
type="button"
className={styles.primaryButton}
onClick={() => setWizardOpen(true)}
disabled={isConnecting}
>
<FaPlus /> {t('Verbindung hinzufügen')}
</button>
)}
</div>
</div>
{/* Sync-in-progress banner */}
{syncBanner && (
<div className={bannerStyles.syncBanner}>
<FaSpinner className={bannerStyles.syncSpinner} />
<div className={bannerStyles.syncText}>
<span className={bannerStyles.syncTitle}>
{t('Wissensdatenbank wird synchronisiert')}
</span>
<span className={bannerStyles.syncDetail}>
{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 },
)}
</span>
</div>
<button
type="button"
className={bannerStyles.syncDismiss}
onClick={dismissSyncBanner}
aria-label={t('Hinweis schließen')}
>
<FaTimes />
</button>
</div>
)}
<div className={styles.tableContainer}>
<FormGeneratorTable
data={connections}
columns={columns}
apiEndpoint="/api/connections/"
tableContextKey="connections"
tableGroupLayoutMode="inline"
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={true}
actionButtons={[
...(canUpdate ? [{
type: 'edit' as const,
onAction: handleEditClick,
title: t('Bearbeiten'),
}] : []),
...(canDelete ? [{
type: 'delete' as const,
title: t('Löschen'),
loading: (row: Connection) => deletingConnections.has(row.id),
}] : []),
]}
customActions={[
{
id: 'connect',
icon: <FaLink />,
onClick: handleConnect,
title: t('Verbinden'),
visible: (row: Connection) => row.status !== 'active',
loading: () => isConnecting,
},
{
id: 'refresh',
icon: <FaRedo />,
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: <FaSyncAlt />,
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')}
/>
</div>
{/* Edit Modal */}
{editingConnection && (
<div className={styles.modalOverlay}>
<div className={styles.modal}>
<div className={styles.modalHeader}>
<h2 className={styles.modalTitle}>{t('Verbindung bearbeiten')}</h2>
<button
className={styles.modalClose}
onClick={() => setEditingConnection(null)}
>
</button>
</div>
<div className={styles.modalContent}>
{formAttributes.length === 0 ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>{t('Formular laden')}</span>
</div>
) : (
<FormGeneratorForm
attributes={formAttributes}
data={editingConnection}
mode="edit"
onSubmit={handleEditSubmit}
onCancel={() => setEditingConnection(null)}
submitButtonText={t('Speichern')}
cancelButtonText={t('Abbrechen')}
/>
)}
</div>
</div>
</div>
)}
<AddConnectionWizard
open={wizardOpen}
onClose={() => setWizardOpen(false)}
onConnect={handleWizardConnect}
onMsftAdminConsent={handleMsftAdminConsent}
isConnecting={isConnecting}
/>
</div>
);
};
export default ConnectionsPage;