1079 lines
No EOL
36 KiB
TypeScript
1079 lines
No EOL
36 KiB
TypeScript
import { useState, useEffect, useCallback } from 'react';
|
||
import { useApiRequest } from './useApi';
|
||
import { getApiBaseUrl } from '../../config/config';
|
||
import api from '../api';
|
||
import { usePermissions, type UserPermissions } from './usePermissions';
|
||
import {
|
||
fetchConnections as fetchConnectionsApi,
|
||
createConnection as createConnectionApi,
|
||
connectService as connectServiceApi,
|
||
disconnectService as disconnectServiceApi,
|
||
deleteConnection as deleteConnectionApi,
|
||
updateConnection as updateConnectionApi,
|
||
refreshMicrosoftToken as refreshMicrosoftTokenApi,
|
||
refreshGoogleToken as refreshGoogleTokenApi,
|
||
submitInfomaniakToken as submitInfomaniakTokenApi,
|
||
type Connection,
|
||
type AttributeDefinition,
|
||
type PaginationParams,
|
||
type CreateConnectionData,
|
||
type ConnectResponse,
|
||
type PaginatedResponse,
|
||
type GroupLayout,
|
||
} from '../api/connectionApi';
|
||
|
||
// Re-export types for backward compatibility
|
||
export type { Connection, AttributeDefinition, PaginationParams, CreateConnectionData, ConnectResponse };
|
||
|
||
// Hook for managing connections
|
||
export function useConnections() {
|
||
const [connections, setConnections] = useState<Connection[]>([]);
|
||
const [attributes, setAttributes] = useState<AttributeDefinition[]>([]);
|
||
const [permissions, setPermissions] = useState<UserPermissions | null>(null);
|
||
const [pagination, setPagination] = useState<{
|
||
currentPage: number;
|
||
pageSize: number;
|
||
totalItems: number;
|
||
totalPages: number;
|
||
} | null>(null);
|
||
const [groupLayout, setGroupLayout] = useState<GroupLayout | null>(null);
|
||
const [appliedView, setAppliedView] = useState<{ viewKey?: string; displayName?: string } | null>(null);
|
||
const [isConnecting, setIsConnecting] = useState(false);
|
||
const [connectError, setConnectError] = useState<string | null>(null);
|
||
const { request, isLoading, error } = useApiRequest<any, any>();
|
||
const { checkPermission } = usePermissions();
|
||
|
||
// Fetch attributes from backend
|
||
const fetchAttributes = useCallback(async () => {
|
||
try {
|
||
const response = await api.get('/api/attributes/UserConnection');
|
||
|
||
// Extract attributes from response
|
||
let attrs: AttributeDefinition[] = [];
|
||
if (response.data?.attributes && Array.isArray(response.data.attributes)) {
|
||
attrs = response.data.attributes;
|
||
} else if (Array.isArray(response.data)) {
|
||
attrs = response.data;
|
||
} else if (response.data && typeof response.data === 'object') {
|
||
const keys = Object.keys(response.data);
|
||
for (const key of keys) {
|
||
if (Array.isArray(response.data[key])) {
|
||
attrs = response.data[key];
|
||
break;
|
||
}
|
||
}
|
||
}
|
||
|
||
setAttributes(attrs);
|
||
return attrs;
|
||
} catch (error: any) {
|
||
console.error('Error fetching attributes:', error);
|
||
setAttributes([]);
|
||
return [];
|
||
}
|
||
}, []);
|
||
|
||
// Fetch permissions from backend
|
||
const fetchPermissions = useCallback(async () => {
|
||
try {
|
||
const perms = await checkPermission('DATA', 'UserConnection');
|
||
setPermissions(perms);
|
||
return perms;
|
||
} catch (error: any) {
|
||
console.error('Error fetching permissions:', error);
|
||
const defaultPerms: UserPermissions = {
|
||
view: false,
|
||
read: 'n',
|
||
create: 'n',
|
||
update: 'n',
|
||
delete: 'n',
|
||
};
|
||
setPermissions(defaultPerms);
|
||
return defaultPerms;
|
||
}
|
||
}, [checkPermission]);
|
||
|
||
const fetchGroupSectionSummaries = useCallback(
|
||
async (base: {
|
||
search?: string;
|
||
filters?: Record<string, any>;
|
||
sort?: Array<{ field: string; direction: string }>;
|
||
viewKey?: string | null;
|
||
groupField: string;
|
||
groupDirection?: 'asc' | 'desc';
|
||
groupByLevels?: Array<{ field: string; nullLabel?: string; direction?: string }>;
|
||
}) => {
|
||
const levels = base.groupByLevels?.length
|
||
? base.groupByLevels
|
||
: [{ field: base.groupField, nullLabel: '—', direction: base.groupDirection || 'asc' }];
|
||
const pObj: Record<string, unknown> = {
|
||
page: 1,
|
||
pageSize: 25,
|
||
groupByLevels: levels,
|
||
};
|
||
if (base.search) (pObj as { search?: string }).search = base.search;
|
||
if (base.filters && Object.keys(base.filters).length) (pObj as { filters: typeof base.filters }).filters = base.filters;
|
||
if (base.sort?.length) (pObj as { sort: typeof base.sort }).sort = base.sort;
|
||
if (base.viewKey) (pObj as { viewKey: string }).viewKey = base.viewKey;
|
||
const { data } = await api.get('/api/connections/', {
|
||
params: { mode: 'groupSummary', pagination: JSON.stringify(pObj) },
|
||
});
|
||
return Array.isArray(data?.groups) ? data.groups : [];
|
||
},
|
||
[],
|
||
);
|
||
|
||
const refetchForSection = useCallback(
|
||
async (
|
||
paginationParams: any,
|
||
sectionFilter: Record<string, unknown>,
|
||
parentColumnFilters?: Record<string, unknown>,
|
||
) => {
|
||
const mergedFilters = {
|
||
...(parentColumnFilters || {}),
|
||
...(paginationParams.filters || {}),
|
||
...sectionFilter,
|
||
};
|
||
const pObj: Record<string, unknown> = {
|
||
page: paginationParams.page,
|
||
pageSize: paginationParams.pageSize,
|
||
filters: mergedFilters,
|
||
groupByLevels: [],
|
||
};
|
||
if (paginationParams.sort) (pObj as { sort: unknown }).sort = paginationParams.sort;
|
||
if (paginationParams.search) (pObj as { search: string }).search = paginationParams.search;
|
||
if (paginationParams.viewKey) (pObj as { viewKey: string }).viewKey = paginationParams.viewKey;
|
||
const { data } = await api.get('/api/connections/', {
|
||
params: { pagination: JSON.stringify(pObj) },
|
||
});
|
||
if (data && typeof data === 'object' && 'items' in data) {
|
||
return { items: data.items, pagination: data.pagination };
|
||
}
|
||
return { items: [], pagination: null };
|
||
},
|
||
[],
|
||
);
|
||
|
||
// Fetch connections with pagination support
|
||
const fetchConnections = useCallback(async (params?: PaginationParams): Promise<Connection[]> => {
|
||
try {
|
||
const data = await fetchConnectionsApi(request, params);
|
||
|
||
// Handle paginated response
|
||
if (data && typeof data === 'object' && 'items' in data) {
|
||
const items = Array.isArray(data.items) ? data.items : [];
|
||
setConnections(items);
|
||
if (data.pagination) {
|
||
setPagination(data.pagination);
|
||
}
|
||
setGroupLayout((data as PaginatedResponse<Connection>).groupLayout ?? null);
|
||
setAppliedView((data as PaginatedResponse<Connection>).appliedView ?? null);
|
||
} else {
|
||
// Handle non-paginated response (backward compatibility)
|
||
const items = Array.isArray(data) ? data : [];
|
||
setConnections(items);
|
||
setPagination(null);
|
||
setGroupLayout(null);
|
||
setAppliedView(null);
|
||
}
|
||
|
||
return Array.isArray(data) ? data : (data?.items || []);
|
||
} catch (error) {
|
||
console.error('Error fetching connections:', error);
|
||
setConnections([]);
|
||
setPagination(null);
|
||
setGroupLayout(null);
|
||
setAppliedView(null);
|
||
throw error;
|
||
}
|
||
}, [request]);
|
||
|
||
// Create a new connection
|
||
const createConnection = async (connectionData: CreateConnectionData): Promise<Connection> => {
|
||
try {
|
||
const data = await createConnectionApi(request, connectionData);
|
||
|
||
// Update local state
|
||
setConnections(prev => {
|
||
const existing = prev.find(conn => conn.id === data.id);
|
||
if (existing) {
|
||
return prev.map(conn => conn.id === data.id ? data : conn);
|
||
} else {
|
||
return [...prev, data];
|
||
}
|
||
});
|
||
|
||
return data;
|
||
} catch (error) {
|
||
console.error('Error creating connection:', error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
// 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<ConnectResponse> => {
|
||
try {
|
||
const data = await connectServiceApi(request, connectionId, reauth);
|
||
return data;
|
||
} catch (error) {
|
||
console.error('Error connecting service:', error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
// Disconnect from a service
|
||
const disconnectService = async (connectionId: string): Promise<{ message: string }> => {
|
||
try {
|
||
const data = await disconnectServiceApi(request, connectionId);
|
||
|
||
// Update local state
|
||
setConnections(prev =>
|
||
prev.map(conn =>
|
||
conn.id === connectionId
|
||
? { ...conn, status: 'inactive' as any, lastChecked: Math.floor(Date.now() / 1000) }
|
||
: conn
|
||
)
|
||
);
|
||
|
||
return data;
|
||
} catch (error) {
|
||
console.error('Error disconnecting service:', error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
// Delete a connection
|
||
const deleteConnection = async (connectionId: string): Promise<{ message: string }> => {
|
||
try {
|
||
const data = await deleteConnectionApi(request, connectionId);
|
||
|
||
// Update local state
|
||
setConnections(prev => prev.filter(conn => conn.id !== connectionId));
|
||
|
||
return data;
|
||
} catch (error) {
|
||
console.error('Error deleting connection:', error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
// Update a connection
|
||
const updateConnection = async (connectionId: string, updateData: Partial<Connection>): Promise<Connection> => {
|
||
try {
|
||
const data = await updateConnectionApi(request, connectionId, updateData);
|
||
|
||
// Update local state
|
||
setConnections(prev =>
|
||
prev.map(conn => conn.id === connectionId ? { ...conn, ...data } : conn)
|
||
);
|
||
|
||
return data;
|
||
} catch (error) {
|
||
console.error('Error updating connection:', error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
// Refresh Microsoft token
|
||
const refreshMicrosoftToken = async (connectionId: string): Promise<Connection> => {
|
||
try {
|
||
const data = await refreshMicrosoftTokenApi(request, connectionId);
|
||
|
||
// Update local state
|
||
setConnections(prev =>
|
||
prev.map(conn => conn.id === connectionId ? { ...conn, ...data } : conn)
|
||
);
|
||
|
||
return data;
|
||
} catch (error) {
|
||
console.error('Error refreshing Microsoft token:', error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
// Refresh Google token
|
||
const refreshGoogleToken = async (connectionId: string): Promise<Connection> => {
|
||
try {
|
||
const data = await refreshGoogleTokenApi(request, connectionId);
|
||
|
||
// Update local state
|
||
setConnections(prev =>
|
||
prev.map(conn => conn.id === connectionId ? { ...conn, ...data } : conn)
|
||
);
|
||
|
||
return data;
|
||
} catch (error) {
|
||
console.error('Error refreshing Google token:', error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
// Connect with popup (OAuth flow)
|
||
const connectWithPopup = async (connectionId: string, reauth: boolean = false): Promise<void> => {
|
||
setIsConnecting(true);
|
||
setConnectError(null);
|
||
|
||
try {
|
||
// Get the OAuth URL from backend
|
||
const response = await connectService(connectionId, reauth);
|
||
if (!response.authUrl) {
|
||
throw new Error('No OAuth URL received from backend');
|
||
}
|
||
|
||
console.log('OAuth URL from backend:', response.authUrl);
|
||
|
||
return new Promise((resolve, reject) => {
|
||
// Convert relative URL to absolute URL if needed
|
||
let authUrl = response.authUrl;
|
||
if (authUrl.startsWith('/')) {
|
||
authUrl = `${getApiBaseUrl()}${authUrl}`;
|
||
}
|
||
|
||
// Open popup
|
||
const popup = window.open(
|
||
authUrl,
|
||
'oauth-connect',
|
||
'width=500,height=600,scrollbars=yes,resizable=yes'
|
||
);
|
||
|
||
if (!popup) {
|
||
setConnectError('Popup was blocked. Please allow popups and try again.');
|
||
setIsConnecting(false);
|
||
reject(new Error('Popup was blocked'));
|
||
return;
|
||
}
|
||
|
||
// Handle popup closing without completing auth
|
||
const checkClosed = setInterval(() => {
|
||
if (popup.closed) {
|
||
clearInterval(checkClosed);
|
||
window.removeEventListener('message', messageListener);
|
||
setIsConnecting(false);
|
||
console.log('OAuth popup closed');
|
||
// Refresh connections anyway in case it succeeded
|
||
fetchConnections();
|
||
resolve(); // Resolve instead of reject to avoid error on normal close
|
||
}
|
||
}, 1000);
|
||
|
||
// Listen for messages from the popup
|
||
const messageListener = (event: MessageEvent) => {
|
||
// Verify origin for security
|
||
const apiUrl = new URL(getApiBaseUrl());
|
||
if (event.origin !== apiUrl.origin) {
|
||
return;
|
||
}
|
||
|
||
if (
|
||
event.data.type === 'msft_connection_success' ||
|
||
event.data.type === 'google_connection_success' ||
|
||
event.data.type === 'clickup_connection_success'
|
||
) {
|
||
// Clean up
|
||
clearInterval(checkClosed);
|
||
window.removeEventListener('message', messageListener);
|
||
popup.close();
|
||
setIsConnecting(false);
|
||
console.log('OAuth connection successful');
|
||
// Refresh connections
|
||
fetchConnections();
|
||
resolve();
|
||
} else if (
|
||
event.data.type === 'msft_connection_error' ||
|
||
event.data.type === 'google_connection_error' ||
|
||
event.data.type === 'clickup_connection_error'
|
||
) {
|
||
// Handle error
|
||
clearInterval(checkClosed);
|
||
window.removeEventListener('message', messageListener);
|
||
popup.close();
|
||
setIsConnecting(false);
|
||
setConnectError(event.data.error || 'OAuth connection failed');
|
||
reject(new Error(event.data.error || 'OAuth connection failed'));
|
||
}
|
||
};
|
||
|
||
// Add message listener
|
||
window.addEventListener('message', messageListener);
|
||
});
|
||
} catch (error: any) {
|
||
setConnectError(error.message || 'OAuth connection failed');
|
||
setIsConnecting(false);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
// Create Google connection and open OAuth popup
|
||
const createGoogleConnectionAndAuth = async (): Promise<void> => {
|
||
if (isConnecting) return;
|
||
setIsConnecting(true);
|
||
try {
|
||
// Step 1: Create a Google connection
|
||
const newConnection = await createConnection({
|
||
type: 'google',
|
||
authority: 'google'
|
||
});
|
||
|
||
// Step 2: Get the OAuth URL from backend for this specific connection
|
||
const connectResponse = await connectServiceApi(request, newConnection.id);
|
||
|
||
if (!connectResponse.authUrl) {
|
||
throw new Error('No OAuth URL received from backend');
|
||
}
|
||
|
||
// Step 3: Open popup to the OAuth URL
|
||
const apiBaseUrl = getApiBaseUrl();
|
||
let authUrl = connectResponse.authUrl;
|
||
if (authUrl.startsWith('/')) {
|
||
authUrl = `${apiBaseUrl}${authUrl}`;
|
||
}
|
||
|
||
return await new Promise<void>((resolve, reject) => {
|
||
const popup = window.open(
|
||
authUrl,
|
||
'google-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;
|
||
}
|
||
|
||
// Handle popup closing without completing auth
|
||
const checkClosed = setInterval(() => {
|
||
if (popup.closed) {
|
||
clearInterval(checkClosed);
|
||
window.removeEventListener('message', messageListener);
|
||
setIsConnecting(false);
|
||
console.log('Google OAuth popup closed');
|
||
// Refresh connections in case it succeeded
|
||
fetchConnections();
|
||
resolve();
|
||
}
|
||
}, 1000);
|
||
|
||
// Listen for messages from the popup
|
||
const messageListener = (event: MessageEvent) => {
|
||
// Verify origin for security
|
||
const apiUrl = new URL(apiBaseUrl);
|
||
if (event.origin !== apiUrl.origin) {
|
||
return;
|
||
}
|
||
|
||
if (event.data.type === 'google_connection_success' || event.data.type === 'google_auth_success') {
|
||
clearInterval(checkClosed);
|
||
window.removeEventListener('message', messageListener);
|
||
popup.close();
|
||
setIsConnecting(false);
|
||
console.log('Google connection successful');
|
||
// Refresh connections
|
||
fetchConnections();
|
||
resolve();
|
||
} else if (event.data.type === 'google_connection_error') {
|
||
clearInterval(checkClosed);
|
||
window.removeEventListener('message', messageListener);
|
||
popup.close();
|
||
setIsConnecting(false);
|
||
reject(new Error(event.data.error || 'Google connection failed'));
|
||
}
|
||
};
|
||
|
||
window.addEventListener('message', messageListener);
|
||
});
|
||
} catch (error) {
|
||
setIsConnecting(false);
|
||
console.error('Error creating Google connection:', error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
// Create ClickUp connection and open OAuth popup
|
||
const createClickupConnectionAndAuth = async (): Promise<void> => {
|
||
if (isConnecting) return;
|
||
setIsConnecting(true);
|
||
try {
|
||
const newConnection = await createConnection({
|
||
type: 'clickup',
|
||
authority: 'clickup',
|
||
});
|
||
|
||
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<void>((resolve, reject) => {
|
||
const popup = window.open(
|
||
authUrl,
|
||
'clickup-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('ClickUp 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 === 'clickup_connection_success') {
|
||
clearInterval(checkClosed);
|
||
window.removeEventListener('message', messageListener);
|
||
popup.close();
|
||
setIsConnecting(false);
|
||
console.log('ClickUp connection successful');
|
||
fetchConnections();
|
||
resolve();
|
||
} else if (event.data.type === 'clickup_connection_error') {
|
||
clearInterval(checkClosed);
|
||
window.removeEventListener('message', messageListener);
|
||
popup.close();
|
||
setIsConnecting(false);
|
||
reject(new Error(event.data.error || 'ClickUp connection failed'));
|
||
}
|
||
};
|
||
|
||
window.addEventListener('message', messageListener);
|
||
});
|
||
} catch (error) {
|
||
setIsConnecting(false);
|
||
console.error('Error creating ClickUp connection:', error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
// 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<Connection> => {
|
||
return await createConnection({
|
||
type: 'infomaniak',
|
||
authority: 'infomaniak',
|
||
});
|
||
};
|
||
|
||
const submitInfomaniakToken = async (
|
||
connectionId: string,
|
||
token: string
|
||
): Promise<void> => {
|
||
await submitInfomaniakTokenApi(request, connectionId, token);
|
||
await fetchConnections();
|
||
};
|
||
|
||
// Create Microsoft connection and open OAuth popup
|
||
const createMicrosoftConnectionAndAuth = async (): Promise<void> => {
|
||
if (isConnecting) return;
|
||
setIsConnecting(true);
|
||
try {
|
||
// Step 1: Create a Microsoft connection
|
||
const newConnection = await createConnection({
|
||
type: 'msft',
|
||
authority: 'msft'
|
||
});
|
||
|
||
// Step 2: Get the OAuth URL from backend for this specific connection
|
||
const connectResponse = await connectServiceApi(request, newConnection.id);
|
||
|
||
if (!connectResponse.authUrl) {
|
||
throw new Error('No OAuth URL received from backend');
|
||
}
|
||
|
||
// Step 3: Open popup to the OAuth URL
|
||
const apiBaseUrl = getApiBaseUrl();
|
||
let authUrl = connectResponse.authUrl;
|
||
if (authUrl.startsWith('/')) {
|
||
authUrl = `${apiBaseUrl}${authUrl}`;
|
||
}
|
||
|
||
return await new Promise<void>((resolve, reject) => {
|
||
const popup = window.open(
|
||
authUrl,
|
||
'msft-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;
|
||
}
|
||
|
||
// Handle popup closing without completing auth
|
||
const checkClosed = setInterval(() => {
|
||
if (popup.closed) {
|
||
clearInterval(checkClosed);
|
||
window.removeEventListener('message', messageListener);
|
||
setIsConnecting(false);
|
||
console.log('Microsoft OAuth popup closed');
|
||
// Refresh connections in case it succeeded
|
||
fetchConnections();
|
||
resolve();
|
||
}
|
||
}, 1000);
|
||
|
||
// Listen for messages from the popup
|
||
const messageListener = (event: MessageEvent) => {
|
||
// Verify origin for security
|
||
const apiUrl = new URL(apiBaseUrl);
|
||
if (event.origin !== apiUrl.origin) {
|
||
return;
|
||
}
|
||
|
||
if (event.data.type === 'msft_connection_success' || event.data.type === 'msft_auth_success') {
|
||
clearInterval(checkClosed);
|
||
window.removeEventListener('message', messageListener);
|
||
popup.close();
|
||
setIsConnecting(false);
|
||
console.log('Microsoft connection successful');
|
||
// Refresh connections
|
||
fetchConnections();
|
||
resolve();
|
||
} else if (event.data.type === 'msft_connection_error') {
|
||
clearInterval(checkClosed);
|
||
window.removeEventListener('message', messageListener);
|
||
popup.close();
|
||
setIsConnecting(false);
|
||
reject(new Error(event.data.error || 'Microsoft connection failed'));
|
||
}
|
||
};
|
||
|
||
window.addEventListener('message', messageListener);
|
||
});
|
||
} catch (error) {
|
||
setIsConnecting(false);
|
||
console.error('Error creating Microsoft connection:', error);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
// Generate edit fields from attributes dynamically
|
||
const generateEditFieldsFromAttributes = useCallback((): Array<{
|
||
key: string;
|
||
label: string;
|
||
type: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'readonly';
|
||
editable?: boolean;
|
||
required?: boolean;
|
||
validator?: (value: any) => string | null;
|
||
minRows?: number;
|
||
maxRows?: number;
|
||
}> => {
|
||
if (!attributes || attributes.length === 0) {
|
||
return [];
|
||
}
|
||
|
||
const editableFields = attributes
|
||
.filter(attr => {
|
||
// Filter out non-editable fields
|
||
const nonEditableFields = ['id', 'userId', 'connectedAt', 'lastChecked'];
|
||
return !nonEditableFields.includes(attr.name);
|
||
})
|
||
.map(attr => {
|
||
// Map attribute type to form field type
|
||
let fieldType: 'string' | 'boolean' | 'email' | 'textarea' | 'date' | 'enum' | 'readonly' = 'string';
|
||
|
||
if (attr.type === 'boolean') {
|
||
fieldType = 'boolean';
|
||
} else if (attr.type === 'date') {
|
||
fieldType = 'date';
|
||
} else if (attr.type === 'enum' && attr.filterOptions) {
|
||
fieldType = 'enum';
|
||
} else if (attr.name.toLowerCase().includes('email')) {
|
||
fieldType = 'email';
|
||
}
|
||
|
||
return {
|
||
key: attr.name,
|
||
label: attr.label || attr.name,
|
||
type: fieldType,
|
||
editable: true,
|
||
required: false
|
||
};
|
||
});
|
||
|
||
return editableFields;
|
||
}, [attributes]);
|
||
|
||
// Ensure attributes are loaded
|
||
const ensureAttributesLoaded = useCallback(async () => {
|
||
if (attributes && attributes.length > 0) {
|
||
return attributes;
|
||
}
|
||
const fetchedAttributes = await fetchAttributes();
|
||
return fetchedAttributes;
|
||
}, [attributes, fetchAttributes]);
|
||
|
||
// Fetch attributes and permissions on mount
|
||
useEffect(() => {
|
||
fetchAttributes();
|
||
fetchPermissions();
|
||
}, [fetchAttributes, fetchPermissions]);
|
||
|
||
// Initial fetch
|
||
useEffect(() => {
|
||
fetchConnections();
|
||
}, [fetchConnections]);
|
||
|
||
// Optimistically update a connection in local state
|
||
const updateOptimistically = useCallback((connectionId: string, updateData: Partial<Connection>) => {
|
||
setConnections(prev =>
|
||
prev.map(conn => conn.id === connectionId ? { ...conn, ...updateData } : conn)
|
||
);
|
||
}, []);
|
||
|
||
// Generic inline update handler for FormGeneratorTable
|
||
const handleInlineUpdate = useCallback(async (connectionId: string, changes: Partial<Connection>, existingRow?: any) => {
|
||
if (!existingRow) {
|
||
throw new Error('Existing row data required for inline update');
|
||
}
|
||
|
||
try {
|
||
const result = await updateConnection(connectionId, changes);
|
||
return { success: true, data: result };
|
||
} catch (error: any) {
|
||
throw new Error(error.message || 'Failed to update');
|
||
}
|
||
}, [updateConnection]);
|
||
|
||
// Fetch connection by ID
|
||
const fetchConnectionById = useCallback(async (connectionId: string): Promise<Connection | null> => {
|
||
try {
|
||
// Since there's no individual connection endpoint, find from current list or fetch all
|
||
const existing = connections.find(c => c.id === connectionId);
|
||
if (existing) return existing;
|
||
|
||
const data = await fetchConnectionsApi(request);
|
||
const items = Array.isArray(data) ? data : (data?.items || []);
|
||
return items.find((c: Connection) => c.id === connectionId) || null;
|
||
} catch (error) {
|
||
console.error('Error fetching connection by ID:', error);
|
||
return null;
|
||
}
|
||
}, [connections, request]);
|
||
|
||
/**
|
||
* Generic wizard entry-point: create a connection of any supported type with
|
||
* optional knowledge consent + preferences, then immediately open the OAuth
|
||
* popup. The three individual `create*ConnectionAndAuth` methods are preserved
|
||
* for backward-compat but new wizard code should call this.
|
||
*/
|
||
const createConnectionAndAuth = async (
|
||
type: 'google' | 'msft' | 'clickup' | 'infomaniak',
|
||
knowledgeIngestionEnabled: boolean,
|
||
knowledgePreferences?: import('../api/connectionApi').KnowledgePreferences | null,
|
||
): Promise<void> => {
|
||
if (isConnecting) return;
|
||
if (type === 'infomaniak') {
|
||
throw new Error('Infomaniak uses PAT flow – use createInfomaniakConnection + submitInfomaniakToken instead.');
|
||
}
|
||
setIsConnecting(true);
|
||
try {
|
||
const newConnection = await createConnection({
|
||
type,
|
||
authority: type,
|
||
knowledgeIngestionEnabled,
|
||
knowledgePreferences: knowledgePreferences ?? null,
|
||
});
|
||
|
||
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<void>((resolve, reject) => {
|
||
const popup = window.open(authUrl, `${type}-wizard`, '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 SUCCESS_TYPES = new Set([
|
||
'google_connection_success', 'msft_connection_success', 'clickup_connection_success',
|
||
'google_auth_success',
|
||
]);
|
||
const ERROR_TYPES = new Set([
|
||
'google_connection_error', 'msft_connection_error', 'clickup_connection_error',
|
||
]);
|
||
|
||
const checkClosed = setInterval(() => {
|
||
if (popup.closed) {
|
||
clearInterval(checkClosed);
|
||
window.removeEventListener('message', messageListener);
|
||
setIsConnecting(false);
|
||
fetchConnections();
|
||
resolve();
|
||
}
|
||
}, 1000);
|
||
|
||
const messageListener = (event: MessageEvent) => {
|
||
const apiUrl = new URL(apiBaseUrl);
|
||
if (event.origin !== apiUrl.origin) return;
|
||
|
||
if (SUCCESS_TYPES.has(event.data.type)) {
|
||
clearInterval(checkClosed);
|
||
window.removeEventListener('message', messageListener);
|
||
popup.close();
|
||
setIsConnecting(false);
|
||
fetchConnections();
|
||
resolve();
|
||
} else if (ERROR_TYPES.has(event.data.type)) {
|
||
clearInterval(checkClosed);
|
||
window.removeEventListener('message', messageListener);
|
||
popup.close();
|
||
setIsConnecting(false);
|
||
reject(new Error(event.data.error || `${type} connection failed`));
|
||
}
|
||
};
|
||
|
||
window.addEventListener('message', messageListener);
|
||
});
|
||
} catch (error: any) {
|
||
setIsConnecting(false);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
return {
|
||
connections,
|
||
data: connections, // Alias for FormGenerator compatibility
|
||
fetchConnections,
|
||
refetch: fetchConnections, // Alias for FormGenerator compatibility
|
||
createConnection,
|
||
updateConnection,
|
||
connectService,
|
||
disconnectService,
|
||
deleteConnection,
|
||
refreshMicrosoftToken,
|
||
refreshGoogleToken,
|
||
connectWithPopup,
|
||
createGoogleConnectionAndAuth,
|
||
createMicrosoftConnectionAndAuth,
|
||
createClickupConnectionAndAuth,
|
||
createInfomaniakConnection,
|
||
submitInfomaniakToken,
|
||
createConnectionAndAuth,
|
||
isLoading,
|
||
loading: isLoading, // Alias for FormGenerator compatibility
|
||
isConnecting,
|
||
error: error || connectError,
|
||
// Attributes and permissions for dynamic column/button generation
|
||
attributes,
|
||
permissions,
|
||
pagination,
|
||
groupLayout,
|
||
appliedView,
|
||
generateEditFieldsFromAttributes,
|
||
ensureAttributesLoaded,
|
||
fetchAttributes,
|
||
fetchPermissions,
|
||
// Additional methods for FormGenerator
|
||
updateOptimistically,
|
||
handleInlineUpdate,
|
||
fetchConnectionById,
|
||
fetchGroupSectionSummaries,
|
||
refetchForSection,
|
||
};
|
||
}
|
||
|
||
// Hook for OAuth connection popup flow (similar to useMsalAuth)
|
||
export function useOAuthConnect() {
|
||
const { connectService, fetchConnections } = useConnections();
|
||
const [isConnecting, setIsConnecting] = useState(false);
|
||
const [connectError, setConnectError] = useState<string | null>(null);
|
||
|
||
const connectWithPopup = async (connectionId: string, reauth: boolean = false): Promise<void> => {
|
||
setIsConnecting(true);
|
||
setConnectError(null);
|
||
|
||
try {
|
||
// Get the OAuth URL from backend
|
||
const response = await connectService(connectionId, reauth);
|
||
if (!response.authUrl) {
|
||
throw new Error('No OAuth URL received from backend');
|
||
}
|
||
|
||
console.log('OAuth URL from backend:', response.authUrl);
|
||
|
||
return new Promise((resolve, reject) => {
|
||
// Convert relative URL to absolute URL if needed
|
||
let authUrl = response.authUrl;
|
||
if (authUrl.startsWith('/')) {
|
||
authUrl = `${getApiBaseUrl()}${authUrl}`;
|
||
}
|
||
|
||
// Open popup using the same pattern as useAuthentication.ts
|
||
const popup = window.open(
|
||
authUrl,
|
||
'oauth-connect',
|
||
'width=500,height=600,scrollbars=yes,resizable=yes'
|
||
);
|
||
|
||
if (!popup) {
|
||
setConnectError('Popup was blocked. Please allow popups and try again.');
|
||
setIsConnecting(false);
|
||
reject(new Error('Popup was blocked'));
|
||
return;
|
||
}
|
||
|
||
// Handle popup closing without completing auth
|
||
const checkClosed = setInterval(() => {
|
||
if (popup.closed) {
|
||
clearInterval(checkClosed);
|
||
window.removeEventListener('message', messageListener);
|
||
setIsConnecting(false);
|
||
console.log('OAuth popup closed');
|
||
// Refresh connections anyway in case it succeeded
|
||
fetchConnections();
|
||
setConnectError('Authentication was cancelled');
|
||
reject(new Error('Authentication was cancelled'));
|
||
}
|
||
}, 1000);
|
||
|
||
// Listen for messages from the popup (similar to useMsalAuth)
|
||
const messageListener = (event: MessageEvent) => {
|
||
// Verify origin for security
|
||
const apiUrl = new URL(getApiBaseUrl());
|
||
if (event.origin !== apiUrl.origin) {
|
||
return;
|
||
}
|
||
|
||
if (
|
||
event.data.type === 'msft_connection_success' ||
|
||
event.data.type === 'google_connection_success' ||
|
||
event.data.type === 'clickup_connection_success'
|
||
) {
|
||
// Clean up - IMPORTANT: clear the checkClosed interval first
|
||
clearInterval(checkClosed);
|
||
window.removeEventListener('message', messageListener);
|
||
popup.close();
|
||
setIsConnecting(false);
|
||
console.log('OAuth connection successful');
|
||
// Refresh connections
|
||
fetchConnections();
|
||
resolve();
|
||
} else if (
|
||
event.data.type === 'msft_connection_error' ||
|
||
event.data.type === 'google_connection_error' ||
|
||
event.data.type === 'clickup_connection_error'
|
||
) {
|
||
// Handle error - also clear the checkClosed interval
|
||
clearInterval(checkClosed);
|
||
window.removeEventListener('message', messageListener);
|
||
popup.close();
|
||
setIsConnecting(false);
|
||
setConnectError(event.data.error || 'OAuth connection failed');
|
||
reject(new Error(event.data.error || 'OAuth connection failed'));
|
||
}
|
||
};
|
||
|
||
// Add message listener
|
||
window.addEventListener('message', messageListener);
|
||
});
|
||
} catch (error: any) {
|
||
setConnectError(error.message || 'OAuth connection failed');
|
||
setIsConnecting(false);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
return {
|
||
connectWithPopup,
|
||
isConnecting,
|
||
error: connectError
|
||
};
|
||
}
|
||
|
||
// Hook for disconnecting services
|
||
export function useDisconnect() {
|
||
const { disconnectService, fetchConnections } = useConnections();
|
||
const [isDisconnecting, setIsDisconnecting] = useState(false);
|
||
const [disconnectError, setDisconnectError] = useState<string | null>(null);
|
||
|
||
const disconnect = async (connectionId: string): Promise<void> => {
|
||
setIsDisconnecting(true);
|
||
setDisconnectError(null);
|
||
|
||
try {
|
||
await disconnectService(connectionId);
|
||
console.log('Service disconnected successfully');
|
||
// Refresh connections to update the status
|
||
await fetchConnections();
|
||
} catch (error: any) {
|
||
setDisconnectError(error.message || 'Disconnect failed');
|
||
console.error('Error disconnecting service:', error);
|
||
throw error;
|
||
} finally {
|
||
setIsDisconnecting(false);
|
||
}
|
||
};
|
||
|
||
return {
|
||
disconnect,
|
||
isDisconnecting,
|
||
error: disconnectError
|
||
};
|
||
}
|
||
|
||
// Hook for individual connection operations
|
||
export function useConnection(connectionId?: string) {
|
||
const [connection, setConnection] = useState<Connection | null>(null);
|
||
const { request, isLoading, error } = useApiRequest<any, Connection[]>();
|
||
|
||
const fetchConnection = async (id: string = connectionId!): Promise<Connection | null> => {
|
||
if (!id) return null;
|
||
|
||
try {
|
||
// Since there's no individual connection endpoint, fetch all and filter
|
||
const data = await fetchConnectionsApi(request);
|
||
const connections = Array.isArray(data) ? data : (data?.items || []);
|
||
|
||
const foundConnection = connections.find((conn: Connection) => conn.id === id);
|
||
setConnection(foundConnection || null);
|
||
return foundConnection || null;
|
||
} catch (error) {
|
||
console.error('Error fetching connection:', error);
|
||
setConnection(null);
|
||
throw error;
|
||
}
|
||
};
|
||
|
||
return {
|
||
connection,
|
||
fetchConnection,
|
||
isLoading,
|
||
error
|
||
};
|
||
}
|