ui-nyla/src/pages/basedata/ConnectionsPage.tsx
Ida f09f43666a merge clickup connector changes
Merge remote-tracking branch 'origin/fix/click-up-connector' into int
2026-04-17 13:35:07 +02:00

392 lines
13 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 } 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 } from 'react-icons/fa';
import { getApiBaseUrl } from '../../../config/config';
import styles from '../admin/Admin.module.css';
import { useLanguage } from '../../providers/language/LanguageContext';
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,
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 [adminConsentPending, setAdminConsentPending] = useState(false);
// Initial fetch
useEffect(() => {
refetch();
}, []);
// Generate columns from attributes - hide internal/redundant fields
const columns = useMemo(() => {
const hiddenColumns = ['id', 'externalId', 'tokenStatus', 'tokenExpiresAt', 'grantedScopes'];
return (attributes || [])
.filter(attr => !hiddenColumns.includes(attr.name))
.map(attr => {
const col: any = {
key: attr.name,
label: attr.label || attr.name,
type: attr.type as any,
sortable: attr.sortable !== false,
filterable: attr.filterable !== false,
searchable: attr.searchable !== false,
width: attr.width || 150,
minWidth: attr.minWidth || 100,
maxWidth: attr.maxWidth || 400,
fkSource: (attr as any).fkSource,
fkDisplayField: (attr as any).fkDisplayField,
};
if (attr.name === 'userId') {
col.fkSource = '/api/users/';
col.fkDisplayField = 'username';
col.label = t('Benutzer');
}
return col;
});
}, [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;
// Note: updateConnection is handled through the hook
try {
// Ensure authority is properly typed - filter and validate authority value
const updateData: Partial<import('../../api/connectionApi').Connection> = { ...data };
// Validate and set authority if present
if (data.authority) {
if (
data.authority === 'local' ||
data.authority === 'google' ||
data.authority === 'msft' ||
data.authority === 'clickup'
) {
updateData.authority = data.authority;
} else {
// Remove invalid authority value
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 create Google connection
const handleCreateGoogle = async () => {
try {
await createGoogleConnectionAndAuth();
refetch();
} catch (error) {
console.error('Error creating Google connection:', error);
}
};
// Handle create Microsoft connection
const handleCreateMicrosoft = async () => {
try {
await createMicrosoftConnectionAndAuth();
refetch();
} catch (error) {
console.error('Error creating Microsoft connection:', error);
}
};
const handleCreateClickup = async () => {
try {
await createClickupConnectionAndAuth();
refetch();
} catch (error) {
console.error('Error creating ClickUp connection:', error);
}
};
// 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'];
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)')}
</p>
</div>
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={handleAdminConsent}
disabled={adminConsentPending}
title={t('Microsoft Admin-Zustimmung erteilt der')}
>
<FaShieldAlt /> {t('Admin-Zustimmung')}
</button>
<button
className={styles.secondaryButton}
onClick={() => refetch()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> {t('Aktualisieren')}
</button>
{canCreate && (
<>
<button
className={styles.googleButton}
onClick={handleCreateGoogle}
disabled={isConnecting}
>
<FaGoogle /> Google
</button>
<button
className={styles.primaryButton}
onClick={handleCreateMicrosoft}
disabled={isConnecting}
>
<FaMicrosoft /> Microsoft
</button>
<button
type="button"
className={styles.clickupButton}
onClick={handleCreateClickup}
disabled={isConnecting}
title={t('ClickUp-Konto verbinden (OAuth oder Personal Token nach Anmeldung)')}
>
<FaTasks /> ClickUp
</button>
</>
)}
</div>
</div>
<div className={styles.tableContainer}>
<FormGeneratorTable
data={connections}
columns={columns}
apiEndpoint="/api/connections/"
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',
loading: (row: Connection) => refreshingConnections.has(row.id),
},
]}
onDelete={handleDelete}
hookData={{
refetch,
permissions,
pagination,
handleDelete: deleteConnection,
handleInlineUpdate,
updateOptimistically,
}}
emptyMessage={t('Keine Verbindungen gefunden')}
/>
</div>
{/* Edit Modal */}
{editingConnection && (
<div className={styles.modalOverlay} onClick={() => setEditingConnection(null)}>
<div className={styles.modal} onClick={e => e.stopPropagation()}>
<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>
)}
</div>
);
};
export default ConnectionsPage;