frontend_nyla/src/pages/basedata/ConnectionsPage.tsx
2026-02-09 23:45:05 +01:00

368 lines
12 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 } from 'react-icons/fa';
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,
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());
// 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') {
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);
}
};
// Form attributes for edit modal
const formAttributes = useMemo(() => {
const excludedFields = ['id', 'mandateId', 'userId', '_createdBy', '_createdAt', '_modifiedAt', '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}>
<div className={styles.pageHeader}>
<div>
<h1 className={styles.pageTitle}>Verbindungen</h1>
<p className={styles.pageSubtitle}>OAuth-Verbindungen verwalten</p>
</div>
<div className={styles.headerActions}>
<button
className={styles.secondaryButton}
onClick={() => refetch()}
disabled={loading}
>
<FaSync className={loading ? 'spinning' : ''} /> Aktualisieren
</button>
{canCreate && (
<>
<button
className={styles.secondaryButton}
onClick={handleCreateGoogle}
disabled={isConnecting}
>
<FaGoogle /> Google
</button>
<button
className={styles.primaryButton}
onClick={handleCreateMicrosoft}
disabled={isConnecting}
>
<FaMicrosoft /> Microsoft
</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- oder Microsoft-Konto, um loszulegen.
</p>
{canCreate && (
<div style={{ display: 'flex', gap: '0.75rem', justifyContent: 'center' }}>
<button
className={styles.secondaryButton}
onClick={handleCreateGoogle}
disabled={isConnecting}
>
<FaGoogle /> Mit Google verbinden
</button>
<button
className={styles.primaryButton}
onClick={handleCreateMicrosoft}
disabled={isConnecting}
>
<FaMicrosoft /> Mit Microsoft 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;