368 lines
12 KiB
TypeScript
368 lines
12 KiB
TypeScript
/**
|
||
* 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;
|