diff --git a/src/api/connectionApi.ts b/src/api/connectionApi.ts index 13ad3cf..b93ce33 100644 --- a/src/api/connectionApi.ts +++ b/src/api/connectionApi.ts @@ -136,14 +136,20 @@ export async function createConnection( /** * Connect to a service (initiate OAuth) * Endpoint: POST /api/connections/{connectionId}/connect + * + * @param reauth If true, forces the OAuth provider to re-show the consent screen. + * Required when newly added scopes (e.g. Calendar/Contacts after a + * feature rollout) need to be granted on top of the existing token. */ export async function connectService( request: ApiRequestFunction, - connectionId: string + connectionId: string, + reauth: boolean = false ): Promise { return await request({ url: `/api/connections/${connectionId}/connect`, - method: 'post' + method: 'post', + data: reauth ? { reauth: true } : undefined, }); } @@ -221,3 +227,28 @@ export async function refreshGoogleToken( }); } +/** + * Submit an Infomaniak Personal Access Token (kdrive + mail) for an existing + * UserConnection. The backend validates the token via /1/profile and stores it + * as the connection's data-access bearer token. + * Endpoint: POST /api/infomaniak/connections/{connectionId}/token + */ +export async function submitInfomaniakToken( + request: ApiRequestFunction, + connectionId: string, + token: string +): Promise<{ + id: string; + status: string; + type: string; + externalUsername: string; + externalEmail?: string | null; + lastChecked: number; +}> { + return await request({ + url: `/api/infomaniak/connections/${connectionId}/token`, + method: 'post', + data: { token } + }); +} + diff --git a/src/components/UnifiedDataBar/SourcesTab.tsx b/src/components/UnifiedDataBar/SourcesTab.tsx index efdc366..33605ad 100644 --- a/src/components/UnifiedDataBar/SourcesTab.tsx +++ b/src/components/UnifiedDataBar/SourcesTab.tsx @@ -142,6 +142,8 @@ const _SERVICE_ICONS: Record = { clickup: '\uD83D\uDCCB', kdrive: '\uD83D\uDCC2', mail: '\uD83D\uDCE7', + calendar: '\uD83D\uDCC5', + contact: '\uD83D\uDC64', }; /* ─── Source colors & icons ──────────────────────────────────────────── */ @@ -166,6 +168,10 @@ const _SOURCE_COLORS: Record = { kdrive: '#0098FF', mailFolder: '#0098FF', mail: '#0098FF', + calendarFolder: '#0098FF', + calendar: '#0098FF', + contactFolder: '#0098FF', + contact: '#0098FF', }; function _getSourceColor(sourceType: string): string { @@ -199,6 +205,8 @@ const _SERVICE_TO_SOURCE_TYPE: Record = { clickup: 'clickup', kdrive: 'kdriveFolder', mail: 'mailFolder', + calendar: 'calendarFolder', + contact: 'contactFolder', }; /* ─── Tree helpers ───────────────────────────────────────────────────── */ diff --git a/src/hooks/useConnections.ts b/src/hooks/useConnections.ts index 0902bef..ad1f308 100644 --- a/src/hooks/useConnections.ts +++ b/src/hooks/useConnections.ts @@ -12,6 +12,7 @@ import { updateConnection as updateConnectionApi, refreshMicrosoftToken as refreshMicrosoftTokenApi, refreshGoogleToken as refreshGoogleTokenApi, + submitInfomaniakToken as submitInfomaniakTokenApi, type Connection, type AttributeDefinition, type PaginationParams, @@ -138,10 +139,12 @@ export function useConnections() { } }; - // Connect to a service (initiate OAuth) - const connectService = async (connectionId: string): Promise => { + // Connect to a service (initiate OAuth). Pass reauth=true to force the + // provider's consent screen so newly added scopes (e.g. Calendar/Contacts) + // actually land on the access token instead of being silently skipped. + const connectService = async (connectionId: string, reauth: boolean = false): Promise => { try { - const data = await connectServiceApi(request, connectionId); + const data = await connectServiceApi(request, connectionId, reauth); return data; } catch (error) { console.error('Error connecting service:', error); @@ -237,13 +240,13 @@ export function useConnections() { }; // Connect with popup (OAuth flow) - const connectWithPopup = async (connectionId: string): Promise => { + const connectWithPopup = async (connectionId: string, reauth: boolean = false): Promise => { setIsConnecting(true); setConnectError(null); try { // Get the OAuth URL from backend - const response = await connectService(connectionId); + const response = await connectService(connectionId, reauth); if (!response.authUrl) { throw new Error('No OAuth URL received from backend'); } @@ -295,8 +298,7 @@ export function useConnections() { if ( event.data.type === 'msft_connection_success' || event.data.type === 'google_connection_success' || - event.data.type === 'clickup_connection_success' || - event.data.type === 'infomaniak_connection_success' + event.data.type === 'clickup_connection_success' ) { // Clean up clearInterval(checkClosed); @@ -310,8 +312,7 @@ export function useConnections() { } else if ( event.data.type === 'msft_connection_error' || event.data.type === 'google_connection_error' || - event.data.type === 'clickup_connection_error' || - event.data.type === 'infomaniak_connection_error' + event.data.type === 'clickup_connection_error' ) { // Handle error clearInterval(checkClosed); @@ -497,82 +498,24 @@ export function useConnections() { } }; - // Create Infomaniak connection and open OAuth popup - const createInfomaniakConnectionAndAuth = async (): Promise => { - if (isConnecting) return; - setIsConnecting(true); - try { - const newConnection = await createConnection({ - type: 'infomaniak', - authority: 'infomaniak', - }); + // Infomaniak uses Personal Access Tokens (no OAuth). Two-step flow: + // 1. createInfomaniakConnection() - creates a PENDING UserConnection row + // 2. submitInfomaniakToken(connectionId, pat) - validates the PAT against + // /1/profile, persists it as the connection's bearer token, and flips + // the row to ACTIVE. + const createInfomaniakConnection = async (): Promise => { + return await createConnection({ + type: 'infomaniak', + authority: 'infomaniak', + }); + }; - const connectResponse = await connectServiceApi(request, newConnection.id); - - if (!connectResponse.authUrl) { - throw new Error('No OAuth URL received from backend'); - } - - const apiBaseUrl = getApiBaseUrl(); - let authUrl = connectResponse.authUrl; - if (authUrl.startsWith('/')) { - authUrl = `${apiBaseUrl}${authUrl}`; - } - - return await new Promise((resolve, reject) => { - const popup = window.open( - authUrl, - 'infomaniak-connection', - 'width=500,height=600,scrollbars=yes,resizable=yes' - ); - - if (!popup) { - setIsConnecting(false); - reject(new Error('Popup was blocked. Please allow popups and try again.')); - return; - } - - const checkClosed = setInterval(() => { - if (popup.closed) { - clearInterval(checkClosed); - window.removeEventListener('message', messageListener); - setIsConnecting(false); - console.log('Infomaniak OAuth popup closed'); - fetchConnections(); - resolve(); - } - }, 1000); - - const messageListener = (event: MessageEvent) => { - const apiUrl = new URL(apiBaseUrl); - if (event.origin !== apiUrl.origin) { - return; - } - - if (event.data.type === 'infomaniak_connection_success') { - clearInterval(checkClosed); - window.removeEventListener('message', messageListener); - popup.close(); - setIsConnecting(false); - console.log('Infomaniak connection successful'); - fetchConnections(); - resolve(); - } else if (event.data.type === 'infomaniak_connection_error') { - clearInterval(checkClosed); - window.removeEventListener('message', messageListener); - popup.close(); - setIsConnecting(false); - reject(new Error(event.data.error || 'Infomaniak connection failed')); - } - }; - - window.addEventListener('message', messageListener); - }); - } catch (error) { - setIsConnecting(false); - console.error('Error creating Infomaniak connection:', error); - throw error; - } + const submitInfomaniakToken = async ( + connectionId: string, + token: string + ): Promise => { + await submitInfomaniakTokenApi(request, connectionId, token); + await fetchConnections(); }; // Create Microsoft connection and open OAuth popup @@ -781,7 +724,8 @@ export function useConnections() { createGoogleConnectionAndAuth, createMicrosoftConnectionAndAuth, createClickupConnectionAndAuth, - createInfomaniakConnectionAndAuth, + createInfomaniakConnection, + submitInfomaniakToken, isLoading, loading: isLoading, // Alias for FormGenerator compatibility isConnecting, @@ -807,13 +751,13 @@ export function useOAuthConnect() { const [isConnecting, setIsConnecting] = useState(false); const [connectError, setConnectError] = useState(null); - const connectWithPopup = async (connectionId: string): Promise => { + const connectWithPopup = async (connectionId: string, reauth: boolean = false): Promise => { setIsConnecting(true); setConnectError(null); try { // Get the OAuth URL from backend - const response = await connectService(connectionId); + const response = await connectService(connectionId, reauth); if (!response.authUrl) { throw new Error('No OAuth URL received from backend'); } @@ -866,8 +810,7 @@ export function useOAuthConnect() { if ( event.data.type === 'msft_connection_success' || event.data.type === 'google_connection_success' || - event.data.type === 'clickup_connection_success' || - event.data.type === 'infomaniak_connection_success' + event.data.type === 'clickup_connection_success' ) { // Clean up - IMPORTANT: clear the checkClosed interval first clearInterval(checkClosed); @@ -881,8 +824,7 @@ export function useOAuthConnect() { } else if ( event.data.type === 'msft_connection_error' || event.data.type === 'google_connection_error' || - event.data.type === 'clickup_connection_error' || - event.data.type === 'infomaniak_connection_error' + event.data.type === 'clickup_connection_error' ) { // Handle error - also clear the checkClosed interval clearInterval(checkClosed); diff --git a/src/pages/basedata/ConnectionsPage.tsx b/src/pages/basedata/ConnectionsPage.tsx index 7a9cae0..24d4fa1 100644 --- a/src/pages/basedata/ConnectionsPage.tsx +++ b/src/pages/basedata/ConnectionsPage.tsx @@ -9,7 +9,7 @@ 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 } from 'react-icons/fa'; +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'; @@ -35,7 +35,8 @@ export const ConnectionsPage: React.FC = () => { createGoogleConnectionAndAuth, createMicrosoftConnectionAndAuth, createClickupConnectionAndAuth, - createInfomaniakConnectionAndAuth, + createInfomaniakConnection, + submitInfomaniakToken, connectWithPopup, refreshMicrosoftToken, refreshGoogleToken, @@ -45,8 +46,18 @@ export const ConnectionsPage: React.FC = () => { const [editingConnection, setEditingConnection] = useState(null); const [deletingConnections, setDeletingConnections] = useState>(new Set()); const [refreshingConnections, setRefreshingConnections] = useState>(new Set()); + const [reconnectingConnections, setReconnectingConnections] = useState>(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(); @@ -172,6 +183,24 @@ export const ConnectionsPage: React.FC = () => { } }; + // 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 () => { @@ -205,15 +234,57 @@ export const ConnectionsPage: React.FC = () => { }; const handleCreateInfomaniak = async () => { - if (isConnecting) return; + if (isConnecting || infomaniakModal) return; try { - await createInfomaniakConnectionAndAuth(); + 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); @@ -360,9 +431,19 @@ export const ConnectionsPage: React.FC = () => { icon: , onClick: handleRefresh, title: t('Token aktualisieren'), - visible: (row: Connection) => row.status === 'active', + visible: (row: Connection) => row.status === 'active' && (row.authority === 'msft' || row.authority === 'google'), loading: (row: Connection) => refreshingConnections.has(row.id), }, + { + id: 'reconnect', + icon: , + 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={{ @@ -411,6 +492,137 @@ export const ConnectionsPage: React.FC = () => { )} + + {/* Infomaniak Personal Access Token Modal */} + {infomaniakModal && ( +
+
+
+

{t('Infomaniak verbinden')}

+ +
+
+

+ {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.' + )} +

+
    +
  1. + {t('Öffne den Infomaniak-Manager:')}{' '} + + manager.infomaniak.com – API-Tokens + +
  2. +
  3. + {t('Klicke auf')} {t('Token erstellen')}{' '} + {t('und vergib einen aussagekräftigen Namen, z. B.')}{' '} + PowerOn.{' '} + {t('Application bleibt auf')} Default application. +
  4. +
  5. + {t('Suche im Scope-Feld nach')}{' '} + {t('allen vier')}{' '} + {t('Berechtigungen und kreuze sie an:')} +
      +
    • + drive — {t('kDrive (Pflicht, heute aktiv)')} +
    • +
    • + workspace:calendar —{' '} + {t('Kalender (Pflicht, heute aktiv)')} +
    • +
    • + workspace:contact —{' '} + {t('Kontakte (heute aktiv)')} +
    • +
    • + workspace:mail —{' '} + {t('Mail (in Vorbereitung, Scope schon mitnehmen)')} +
    • +
    + + {t( + 'Nicht "All" auswählen und nicht user_info / accounts — wird nicht benötigt.' + )} + +
  6. +
  7. + {t( + 'Kopiere das Token sofort (Infomaniak zeigt es nur einmal) und füge es hier ein:' + )} +
  8. +
+ + 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 && ( +
+ {infomaniakModal.error} +
+ )} +

+ {t( + 'Das Token wird verschlüsselt gespeichert und nur für API-Aufrufe an Infomaniak verwendet.' + )} +

+
+ + +
+
+
+
+ )} ); }; diff --git a/src/pages/basedata/FilesPage.tsx b/src/pages/basedata/FilesPage.tsx index a65ad5b..e7c98d3 100644 --- a/src/pages/basedata/FilesPage.tsx +++ b/src/pages/basedata/FilesPage.tsx @@ -225,6 +225,20 @@ export const FilesPage: React.FC = () => { maxWidth: 250, displayField: 'sysCreatedByLabel', } as any); + // sysModifiedAt is marked frontend_visible=false in PowerOnModel so it + // never reaches us via the /api/attributes endpoint - declare type + // explicitly so the FormGenerator renders it as a timestamp. + cols.push({ + key: 'sysModifiedAt', + label: t('Geaendert am'), + type: 'timestamp', + sortable: true, + filterable: true, + searchable: false, + width: 170, + minWidth: 130, + maxWidth: 220, + } as any); return resolveColumnTypes(cols, attributes || []); }, [attributes, t]);