frontend_nyla/src/pages/basedata/ConnectionsPage.tsx

429 lines
14 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, FaPlug, FaGoogle, FaMicrosoft, FaLink, FaRedo, FaShieldAlt, FaTasks } from 'react-icons/fa';
import { getApiBaseUrl } from '../../../config/config';
import styles from '../admin/Admin.module.css';
export const ConnectionsPage: React.FC = () => {
// 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,
};
// Resolve userId to username via FK
if (attr.name === 'userId') {
col.fkSource = '/api/users/';
col.fkDisplayField = 'username';
col.label = 'User';
}
return col;
});
}, [attributes]);
// 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);
}
};
// Handle create ClickUp connection
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}>Fehler beim Laden der Verbindungen: {error}</p>
<button className={styles.secondaryButton} onClick={() => refetch()}>
<FaSync /> Erneut versuchen
</button>
</div>
</div>
);
}
return (
<div className={`${styles.adminPage} ${styles.adminPageFill}`}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Verbindungen</h1>
<p className={styles.pageSubtitle}>
Persönliche Datenanbindungen verwalten (OAuth: Google, Microsoft, ClickUp)
</p>
</div>
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={handleAdminConsent}
disabled={adminConsentPending}
title="Microsoft Admin Consent — erteilt der App die nötigen Berechtigungen für den gesamten Tenant"
>
<FaShieldAlt /> Admin Consent
</button>
<button
className={styles.secondaryButton}
onClick={() => refetch()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> 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
className={styles.clickupButton}
onClick={handleCreateClickup}
disabled={isConnecting}
title="ClickUp-Konto verbinden"
>
<FaTasks /> ClickUp
</button>
</>
)}
</div>
</div>
<div className={styles.tableContainer}>
{loading && (!connections || connections.length === 0) ? (
<div className={styles.loadingContainer}>
<div className={styles.spinner} />
<span>Lade Verbindungen...</span>
</div>
) : !connections || connections.length === 0 ? (
<div className={styles.emptyState}>
<FaPlug className={styles.emptyIcon} />
<h3 className={styles.emptyTitle}>Keine Verbindungen vorhanden</h3>
<p className={styles.emptyDescription}>
Verbinden Sie Ihr Google-, Microsoft- oder ClickUp-Konto, um loszulegen.
</p>
{canCreate && (
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center', flexWrap: 'wrap' }}>
<button
className={styles.googleButton}
onClick={handleCreateGoogle}
disabled={isConnecting}
>
<FaGoogle /> Mit Google verbinden
</button>
<button
className={styles.primaryButton}
onClick={handleCreateMicrosoft}
disabled={isConnecting}
>
<FaMicrosoft /> Mit Microsoft verbinden
</button>
<button
className={styles.clickupButton}
onClick={handleCreateClickup}
disabled={isConnecting}
>
<FaTasks /> Mit ClickUp verbinden
</button>
</div>
)}
</div>
) : (
<FormGeneratorTable
data={connections}
columns={columns}
apiEndpoint="/api/connections/"
loading={loading}
pagination={true}
pageSize={25}
searchable={true}
filterable={true}
sortable={true}
selectable={false}
actionButtons={[
...(canUpdate ? [{
type: 'edit' as const,
onAction: handleEditClick,
title: 'Bearbeiten',
}] : []),
...(canDelete ? [{
type: 'delete' as const,
title: 'Löschen',
loading: (row: Connection) => deletingConnections.has(row.id),
}] : []),
]}
customActions={[
{
id: 'connect',
icon: <FaLink />,
onClick: handleConnect,
title: 'Verbinden',
visible: (row: Connection) => row.status !== 'active',
loading: () => isConnecting,
},
{
id: 'refresh',
icon: <FaRedo />,
onClick: handleRefresh,
title: 'Token erneuern',
visible: (row: Connection) => row.status === 'active',
loading: (row: Connection) => refreshingConnections.has(row.id),
},
]}
onDelete={handleDelete}
hookData={{
refetch,
permissions,
pagination,
handleDelete: deleteConnection,
handleInlineUpdate,
updateOptimistically,
}}
emptyMessage="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}>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>Lade Formular...</span>
</div>
) : (
<FormGeneratorForm
attributes={formAttributes}
data={editingConnection}
mode="edit"
onSubmit={handleEditSubmit}
onCancel={() => setEditingConnection(null)}
submitButtonText="Speichern"
cancelButtonText="Abbrechen"
/>
)}
</div>
</div>
</div>
)}
</div>
);
};
export default ConnectionsPage;