630 lines
22 KiB
TypeScript
630 lines
22 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, FaGoogle, FaMicrosoft, FaLink, FaRedo, FaShieldAlt, FaTasks, FaCloud, FaSyncAlt } from 'react-icons/fa';
|
||
import { getApiBaseUrl } from '../../../config/config';
|
||
import styles from '../admin/Admin.module.css';
|
||
|
||
import { useLanguage } from '../../providers/language/LanguageContext';
|
||
import { resolveColumnTypes } from '../../utils/columnTypeResolver';
|
||
|
||
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,
|
||
createInfomaniakConnection,
|
||
submitInfomaniakToken,
|
||
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 [adminConsentPending, setAdminConsentPending] = useState(false);
|
||
|
||
// Infomaniak PAT modal: holds the pending connectionId (created up-front so the
|
||
// user only commits if they actually paste a valid token; on cancel we delete it).
|
||
const [infomaniakModal, setInfomaniakModal] = useState<{
|
||
connectionId: string;
|
||
token: string;
|
||
submitting: boolean;
|
||
error: string | null;
|
||
} | null>(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;
|
||
});
|
||
}
|
||
};
|
||
|
||
// Guards prevent double-trigger while the OAuth popup is open, which would
|
||
// otherwise create additional orphan PENDING connections on every click.
|
||
const handleCreateGoogle = async () => {
|
||
if (isConnecting) return;
|
||
try {
|
||
await createGoogleConnectionAndAuth();
|
||
refetch();
|
||
} catch (error) {
|
||
console.error('Error creating Google connection:', error);
|
||
}
|
||
};
|
||
|
||
const handleCreateMicrosoft = async () => {
|
||
if (isConnecting) return;
|
||
try {
|
||
await createMicrosoftConnectionAndAuth();
|
||
refetch();
|
||
} catch (error) {
|
||
console.error('Error creating Microsoft connection:', error);
|
||
}
|
||
};
|
||
|
||
const handleCreateClickup = async () => {
|
||
if (isConnecting) return;
|
||
try {
|
||
await createClickupConnectionAndAuth();
|
||
refetch();
|
||
} catch (error) {
|
||
console.error('Error creating ClickUp connection:', error);
|
||
}
|
||
};
|
||
|
||
const handleCreateInfomaniak = async () => {
|
||
if (isConnecting || infomaniakModal) return;
|
||
try {
|
||
const newConnection = await createInfomaniakConnection();
|
||
setInfomaniakModal({
|
||
connectionId: newConnection.id,
|
||
token: '',
|
||
submitting: false,
|
||
error: null,
|
||
});
|
||
refetch();
|
||
} catch (error) {
|
||
console.error('Error creating Infomaniak connection:', error);
|
||
}
|
||
};
|
||
|
||
const handleInfomaniakCancel = async () => {
|
||
if (!infomaniakModal) return;
|
||
const { connectionId, submitting } = infomaniakModal;
|
||
if (submitting) return;
|
||
setInfomaniakModal(null);
|
||
try {
|
||
await deleteConnection(connectionId);
|
||
refetch();
|
||
} catch (error) {
|
||
console.error('Error rolling back pending Infomaniak connection:', error);
|
||
}
|
||
};
|
||
|
||
const handleInfomaniakSubmit = async () => {
|
||
if (!infomaniakModal) return;
|
||
const trimmed = infomaniakModal.token.trim();
|
||
if (!trimmed) {
|
||
setInfomaniakModal({ ...infomaniakModal, error: t('Bitte Personal Access Token einfügen') });
|
||
return;
|
||
}
|
||
setInfomaniakModal({ ...infomaniakModal, submitting: true, error: null });
|
||
try {
|
||
await submitInfomaniakToken(infomaniakModal.connectionId, trimmed);
|
||
setInfomaniakModal(null);
|
||
refetch();
|
||
} catch (error: any) {
|
||
const detail =
|
||
error?.response?.data?.detail ||
|
||
error?.message ||
|
||
t('Token konnte nicht gespeichert werden');
|
||
setInfomaniakModal((prev) =>
|
||
prev ? { ...prev, submitting: false, error: String(detail) } : prev
|
||
);
|
||
}
|
||
};
|
||
|
||
// 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',
|
||
// 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={handleAdminConsent}
|
||
disabled={adminConsentPending}
|
||
title={t('Microsoft Admin-Zustimmung für die gesamte Organisation erteilen')}
|
||
>
|
||
<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>
|
||
<button
|
||
type="button"
|
||
className={styles.secondaryButton}
|
||
onClick={handleCreateInfomaniak}
|
||
disabled={isConnecting}
|
||
title={t('Infomaniak-Konto verbinden (kDrive + Mail)')}
|
||
>
|
||
<FaCloud /> Infomaniak
|
||
</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' && (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,
|
||
handleDelete: deleteConnection,
|
||
handleInlineUpdate,
|
||
updateOptimistically,
|
||
}}
|
||
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>
|
||
)}
|
||
|
||
{/* Infomaniak Personal Access Token Modal */}
|
||
{infomaniakModal && (
|
||
<div className={styles.modalOverlay}>
|
||
<div className={styles.modal} style={{ maxWidth: 640 }}>
|
||
<div className={styles.modalHeader}>
|
||
<h2 className={styles.modalTitle}>{t('Infomaniak verbinden')}</h2>
|
||
<button
|
||
className={styles.modalClose}
|
||
onClick={handleInfomaniakCancel}
|
||
disabled={infomaniakModal.submitting}
|
||
>
|
||
✕
|
||
</button>
|
||
</div>
|
||
<div className={styles.modalContent}>
|
||
<p style={{ marginTop: 0 }}>
|
||
{t(
|
||
'Infomaniak nutzt für kDrive und kSuite keine OAuth-Anmeldung, sondern ein persönliches API-Token (PAT). Erstelle das Token einmalig im Infomaniak Manager und füge es unten ein.'
|
||
)}
|
||
</p>
|
||
<ol style={{ paddingLeft: 20 }}>
|
||
<li>
|
||
{t('Öffne den Infomaniak-Manager:')}{' '}
|
||
<a
|
||
href="https://manager.infomaniak.com/v3/ng/accounts/token/list"
|
||
target="_blank"
|
||
rel="noopener noreferrer"
|
||
>
|
||
manager.infomaniak.com – API-Tokens
|
||
</a>
|
||
</li>
|
||
<li>
|
||
{t('Klicke auf')} <code>{t('Token erstellen')}</code>{' '}
|
||
{t('und vergib einen aussagekräftigen Namen, z. B.')}{' '}
|
||
<code>PowerOn</code>.{' '}
|
||
{t('Application bleibt auf')} <code>Default application</code>.
|
||
</li>
|
||
<li>
|
||
{t('Suche im Scope-Feld nach')}{' '}
|
||
<strong>{t('allen vier')}</strong>{' '}
|
||
{t('Berechtigungen und kreuze sie an:')}
|
||
<ul style={{ marginTop: 6, marginBottom: 6, paddingLeft: 20 }}>
|
||
<li>
|
||
<code>drive</code> — {t('kDrive (Pflicht, heute aktiv)')}
|
||
</li>
|
||
<li>
|
||
<code>workspace:calendar</code> —{' '}
|
||
{t('Kalender (Pflicht, heute aktiv)')}
|
||
</li>
|
||
<li>
|
||
<code>workspace:contact</code> —{' '}
|
||
{t('Kontakte (heute aktiv)')}
|
||
</li>
|
||
<li>
|
||
<code>workspace:mail</code> —{' '}
|
||
{t('Mail (in Vorbereitung, Scope schon mitnehmen)')}
|
||
</li>
|
||
</ul>
|
||
<em>
|
||
{t(
|
||
'Nicht "All" auswählen und nicht user_info / accounts — wird nicht benötigt.'
|
||
)}
|
||
</em>
|
||
</li>
|
||
<li>
|
||
{t(
|
||
'Kopiere das Token sofort (Infomaniak zeigt es nur einmal) und füge es hier ein:'
|
||
)}
|
||
</li>
|
||
</ol>
|
||
<input
|
||
type="password"
|
||
value={infomaniakModal.token}
|
||
onChange={(e) =>
|
||
setInfomaniakModal((prev) =>
|
||
prev ? { ...prev, token: e.target.value, error: null } : prev
|
||
)
|
||
}
|
||
placeholder={t('Personal Access Token einfügen')}
|
||
disabled={infomaniakModal.submitting}
|
||
autoFocus
|
||
style={{
|
||
width: '100%',
|
||
padding: '8px 10px',
|
||
fontFamily: 'monospace',
|
||
fontSize: 13,
|
||
border: '1px solid var(--border, #ccc)',
|
||
borderRadius: 4,
|
||
marginBottom: 12,
|
||
}}
|
||
/>
|
||
{infomaniakModal.error && (
|
||
<div className={styles.errorMessage} style={{ marginBottom: 12 }}>
|
||
{infomaniakModal.error}
|
||
</div>
|
||
)}
|
||
<p style={{ fontSize: 12, color: 'var(--text-secondary, #666)' }}>
|
||
{t(
|
||
'Das Token wird verschlüsselt gespeichert und nur für API-Aufrufe an Infomaniak verwendet.'
|
||
)}
|
||
</p>
|
||
<div
|
||
style={{
|
||
display: 'flex',
|
||
justifyContent: 'flex-end',
|
||
gap: 8,
|
||
marginTop: 16,
|
||
}}
|
||
>
|
||
<button
|
||
type="button"
|
||
className={styles.secondaryButton}
|
||
onClick={handleInfomaniakCancel}
|
||
disabled={infomaniakModal.submitting}
|
||
>
|
||
{t('Abbrechen')}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
className={styles.primaryButton}
|
||
onClick={handleInfomaniakSubmit}
|
||
disabled={infomaniakModal.submitting || !infomaniakModal.token.trim()}
|
||
>
|
||
{infomaniakModal.submitting ? t('Prüfen…') : t('Verbinden')}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
};
|
||
|
||
export default ConnectionsPage;
|