From b61544d8b1dbf99a19676625e89de2d7c33a04f3 Mon Sep 17 00:00:00 2001 From: Ida Date: Wed, 29 Apr 2026 09:16:09 +0200 Subject: [PATCH] feat: rag extension frontend consent --- src/api/connectionApi.ts | 20 + .../AddConnectionWizard.module.css | 467 ++++++++++++++++ .../AddConnectionWizard.tsx | 521 ++++++++++++++++++ src/hooks/useConnections.ts | 85 +++ src/pages/basedata/ConnectionsPage.module.css | 90 +++ src/pages/basedata/ConnectionsPage.tsx | 123 +++-- 6 files changed, 1256 insertions(+), 50 deletions(-) create mode 100644 src/components/AddConnectionWizard/AddConnectionWizard.module.css create mode 100644 src/components/AddConnectionWizard/AddConnectionWizard.tsx create mode 100644 src/pages/basedata/ConnectionsPage.module.css diff --git a/src/api/connectionApi.ts b/src/api/connectionApi.ts index b93ce33..67d6750 100644 --- a/src/api/connectionApi.ts +++ b/src/api/connectionApi.ts @@ -4,6 +4,22 @@ import { ApiRequestOptions } from '../hooks/useApi'; // TYPES & INTERFACES // ============================================================================ +export interface KnowledgePreferences { + schemaVersion?: number; + neutralizeBeforeEmbed?: boolean; + mailContentDepth?: 'metadata' | 'snippet' | 'full'; + mailIndexAttachments?: boolean; + filesIndexBinaries?: boolean; + mimeAllowlist?: string[]; + clickupScope?: 'titles' | 'title_description' | 'with_comments'; + clickupIndexAttachments?: boolean; + surfaceToggles?: { + google?: { gmail?: boolean; drive?: boolean }; + msft?: { sharepoint?: boolean; outlook?: boolean }; + }; + maxAgeDays?: number; +} + export interface Connection { id: string; userId: string; @@ -15,6 +31,8 @@ export interface Connection { connectedAt: number; // Backend uses float for UTC timestamp in seconds lastChecked: number; // Backend uses float for UTC timestamp in seconds expiresAt?: number; // Backend uses Optional[float] for UTC timestamp in seconds + knowledgeIngestionEnabled?: boolean; + knowledgePreferences?: KnowledgePreferences | null; [key: string]: any; // Allow additional properties } @@ -58,6 +76,8 @@ export interface CreateConnectionData { externalUsername?: string; externalEmail?: string; status?: 'active' | 'expired' | 'revoked' | 'pending'; + knowledgeIngestionEnabled?: boolean; + knowledgePreferences?: KnowledgePreferences | null; connectedAt?: number; lastChecked?: number; expiresAt?: number; diff --git a/src/components/AddConnectionWizard/AddConnectionWizard.module.css b/src/components/AddConnectionWizard/AddConnectionWizard.module.css new file mode 100644 index 0000000..5cabd64 --- /dev/null +++ b/src/components/AddConnectionWizard/AddConnectionWizard.module.css @@ -0,0 +1,467 @@ +/* AddConnectionWizard styles */ + +.stepper { + display: flex; + justify-content: center; + gap: 1.5rem; + padding: 1rem 1.5rem 0; + border-bottom: 1px solid var(--border-color); +} + +.stepDot { + width: 28px; + height: 28px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + font-size: 0.75rem; + font-weight: 600; + background: var(--bg-secondary, #f0f0f0); + color: var(--text-secondary, #666); + border: 2px solid var(--border-color, #ddd); + transition: background 0.2s, border-color 0.2s, color 0.2s; +} + +.stepDotActive { + background: var(--primary-color, #f25843); + border-color: var(--primary-color, #f25843); + color: white; +} + +.stepDotDone { + background: var(--success-color, #22c55e); + border-color: var(--success-color, #22c55e); + color: white; +} + +.stepDotHidden { + opacity: 0.3; +} + +.body { + padding: 1.5rem; + overflow-y: auto; +} + +.stepContent { + display: flex; + flex-direction: column; + gap: 1rem; + min-height: 220px; +} + +.stepTitle { + font-size: 1.125rem; + font-weight: 600; + color: var(--text-primary); + margin: 0; +} + +.stepBody { + font-size: 0.9375rem; + color: var(--text-primary); + line-height: 1.6; + margin: 0; +} + +.stepHint { + font-size: 0.8125rem; + color: var(--text-secondary, #666); + margin: 0; +} + +/* Connector grid (Step 0) */ +.connectorGrid { + display: flex; + gap: 1rem; + flex-wrap: wrap; +} + +.connectorCard { + flex: 1 1 140px; + display: flex; + flex-direction: column; + align-items: center; + gap: 0.625rem; + padding: 1.25rem 1rem; + background: var(--surface-color); + border: 2px solid var(--border-color, #ddd); + border-radius: 10px; + cursor: pointer; + transition: border-color 0.15s, box-shadow 0.15s, transform 0.1s; +} + +.connectorCard:hover { + border-color: var(--primary-color, #f25843); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08); + transform: translateY(-2px); +} + +.connectorIcon { + font-size: 1.75rem; +} + +.connectorLabel { + font-size: 0.875rem; + font-weight: 500; + color: var(--text-primary); +} + +/* Consent step (Step 1) */ +.consentIcon { + display: flex; + justify-content: center; + color: var(--primary-color, #f25843); +} + +.consentButtons { + display: flex; + gap: 0.75rem; + flex-wrap: wrap; +} + +.consentButtonYes { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.75rem 1.25rem; + background: var(--primary-color, #f25843); + color: white; + border: none; + border-radius: 8px; + font-size: 0.9375rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.consentButtonYes:hover { + background: var(--primary-dark, #d94d3a); +} + +.consentButtonNo { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + gap: 0.5rem; + padding: 0.75rem 1.25rem; + background: var(--surface-color); + color: var(--text-primary); + border: 2px solid var(--border-color, #ddd); + border-radius: 8px; + font-size: 0.9375rem; + font-weight: 500; + cursor: pointer; + transition: border-color 0.2s, background 0.2s; +} + +.consentButtonNo:hover { + border-color: var(--text-secondary, #888); + background: var(--bg-secondary, #f5f5f5); +} + +/* Preferences step (Step 2) */ +.prefGroup { + display: flex; + flex-direction: column; + gap: 0.25rem; + padding: 0.75rem 0; + border-bottom: 1px solid var(--border-color, #eee); +} + +.prefGroup:last-of-type { + border-bottom: none; +} + +.prefLabel { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; + font-size: 0.9375rem; + color: var(--text-primary); + cursor: pointer; + font-weight: 500; +} + +.prefLabelRow { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + font-size: 0.9375rem; + color: var(--text-primary); + font-weight: 500; +} + +.prefIcon { + color: var(--text-secondary, #666); + font-size: 0.875rem; +} + +.prefCheck { + width: 18px; + height: 18px; + cursor: pointer; + accent-color: var(--primary-color, #f25843); +} + +.prefSelect { + padding: 0.375rem 0.5rem; + border: 1px solid var(--border-color, #ddd); + border-radius: 6px; + font-size: 0.875rem; + background: var(--surface-color); + color: var(--text-primary); + min-width: 200px; +} + +.prefNumber { + width: 80px; + padding: 0.375rem 0.5rem; + border: 1px solid var(--border-color, #ddd); + border-radius: 6px; + font-size: 0.875rem; + background: var(--surface-color); + color: var(--text-primary); + text-align: right; +} + +.prefHint { + font-size: 0.8125rem; + color: var(--text-secondary, #666); + margin: 0; +} + +/* Summary step (Step 3) */ +.summary { + display: flex; + flex-direction: column; + gap: 0; + border: 1px solid var(--border-color, #ddd); + border-radius: 8px; + overflow: hidden; +} + +.summaryRow { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.625rem 1rem; + gap: 1rem; + border-bottom: 1px solid var(--border-color, #eee); +} + +.summaryRow:last-child { + border-bottom: none; +} + +.summaryKey { + font-size: 0.875rem; + color: var(--text-secondary, #666); + font-weight: 500; +} + +.summaryVal { + display: flex; + align-items: center; + gap: 0.375rem; + font-size: 0.875rem; + color: var(--text-primary); + font-weight: 500; +} + +/* Back button (step 1 consent screen) */ +.stepNavLeft { + margin-top: 0.75rem; + display: flex; +} + +.navBack { + background: none; + border: none; + padding: 0.25rem 0; + font-size: 0.8125rem; + color: var(--text-secondary, #666); + cursor: pointer; + text-decoration: underline; +} + +.navBack:hover { + color: var(--text-primary); +} + +/* Cost estimate hint */ +.costHint { + display: flex; + align-items: flex-start; + gap: 0.625rem; + padding: 0.75rem 1rem; + background: var(--info-bg, #eff6ff); + border: 1px solid var(--info-border, #bfdbfe); + border-radius: 8px; + font-size: 0.8125rem; +} + +.costHintIcon { + flex-shrink: 0; + margin-top: 2px; + color: var(--info-color, #3b82f6); +} + +.costHint > div { + display: flex; + flex-direction: column; + gap: 0.25rem; + width: 100%; +} + +.costHintTitle { + font-weight: 600; + color: var(--text-primary); + margin-bottom: 0.125rem; +} + +.costTable { + border-collapse: collapse; + width: 100%; + font-size: 0.8125rem; +} + +.costLabel { + color: var(--text-secondary, #555); + padding-right: 1rem; + white-space: nowrap; +} + +.costVal { + font-weight: 600; + color: var(--info-color, #1d4ed8); +} + +.costRowNeut .costLabel, +.costRowNeut .costVal { + padding-top: 0.125rem; +} + +.costRowNeut .costVal { + color: #b45309; +} + +.costHintWarn { + font-size: 0.75rem; + color: #b45309; + font-weight: 500; + line-height: 1.4; +} + +.costHintNote { + color: var(--text-secondary, #555); + font-size: 0.75rem; +} + +:global(.dark-theme) .costHint { + background: rgba(59, 130, 246, 0.08); + border-color: rgba(59, 130, 246, 0.3); +} + +:global(.dark-theme) .costVal { + color: #93c5fd; +} + +:global(.dark-theme) .costRowNeut .costVal, +:global(.dark-theme) .costHintWarn { + color: #fbbf24; +} + +/* Navigation */ +.stepNav { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: auto; + padding-top: 0.5rem; + gap: 0.75rem; +} + +.navBack { + padding: 0.5rem 1rem; + background: var(--surface-color); + color: var(--text-secondary, #666); + border: 1px solid var(--border-color, #ddd); + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.navBack:hover { + background: var(--bg-secondary, #f5f5f5); +} + +.navNext { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.5rem 1.25rem; + background: var(--primary-color, #f25843); + color: white; + border: none; + border-radius: 6px; + font-size: 0.875rem; + font-weight: 500; + cursor: pointer; + transition: background 0.2s; +} + +.navNext:hover { + background: var(--primary-dark, #d94d3a); +} + +.navConnect { + display: flex; + align-items: center; + gap: 0.375rem; + padding: 0.625rem 1.5rem; + background: var(--primary-color, #f25843); + color: white; + border: none; + border-radius: 6px; + font-size: 0.9375rem; + font-weight: 600; + cursor: pointer; + transition: background 0.2s; +} + +.navConnect:hover:not(:disabled) { + background: var(--primary-dark, #d94d3a); +} + +.navConnect:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* Dark theme */ +:global(.dark-theme) .connectorCard { + background: var(--surface-color); +} + +:global(.dark-theme) .prefSelect, +:global(.dark-theme) .prefNumber { + background: var(--surface-color); + color: var(--text-primary); +} + +:global(.dark-theme) .summary { + border-color: var(--border-color); +} + +:global(.dark-theme) .summaryRow { + border-color: var(--border-color); +} diff --git a/src/components/AddConnectionWizard/AddConnectionWizard.tsx b/src/components/AddConnectionWizard/AddConnectionWizard.tsx new file mode 100644 index 0000000..ca5dbac --- /dev/null +++ b/src/components/AddConnectionWizard/AddConnectionWizard.tsx @@ -0,0 +1,521 @@ +/** + * AddConnectionWizard + * + * Multi-step modal for adding a new connector with optional knowledge + * ingestion consent and per-connection preferences (§2.6). + * + * Steps: + * 0 — Connector wählen + * 1 — Consent (Wissensdatenbank Ja/Nein) + * 2 — Präferenzen (nur wenn Ja) + * 3 — Zusammenfassung + OAuth starten + */ + +import React, { useState } from 'react'; +import { Modal } from '../UiComponents/Modal/Modal'; +import { FaGoogle, FaMicrosoft, FaTasks, FaDatabase, FaShieldAlt, FaCheck, FaArrowRight, FaInfoCircle } from 'react-icons/fa'; +import type { KnowledgePreferences } from '../../api/connectionApi'; +import styles from './AddConnectionWizard.module.css'; + +// --------------------------------------------------------------------------- +// Types +// --------------------------------------------------------------------------- + +export type ConnectorType = 'google' | 'msft' | 'clickup'; + +interface WizardState { + step: 0 | 1 | 2 | 3; + connector: ConnectorType | null; + knowledgeEnabled: boolean; + prefs: KnowledgePreferences; +} + +const DEFAULT_PREFS: KnowledgePreferences = { + schemaVersion: 1, + neutralizeBeforeEmbed: false, + mailContentDepth: 'full', + mailIndexAttachments: false, + filesIndexBinaries: true, + clickupScope: 'title_description', + clickupIndexAttachments: false, + maxAgeDays: 90, +}; + +const CONNECTOR_LABELS: Record = { + google: 'Google', + msft: 'Microsoft 365', + clickup: 'ClickUp', +}; + +const CONNECTOR_ICONS: Record = { + google: , + msft: , + clickup: , +}; + +// --------------------------------------------------------------------------- +// Cost estimate helper +// --------------------------------------------------------------------------- + +/** + * Returns a cost estimate broken into two lines: + * + * 1. Embedding (OpenAI text-embedding-3-small, $0.02 / 1M tokens) — always tiny. + * 2. Neutralization (Private LLM / qwen2.5 on-premise, CHF 0.01 per LLM call) + * — this is the DOMINANT cost when enabled. One call per email/task for + * short content; several calls for long threads or files. + * + * Numbers are conservative ranges. Subsequent syncs are cheaper because + * unchanged content is deduplicated before any LLM/embedding call. + */ +function computeCostEstimate( + connector: ConnectorType | null, + prefs: KnowledgePreferences, +): { + embeddingLow: string; + embeddingHigh: string; + neutralizationLow: string | null; + neutralizationHigh: string | null; + note: string; +} | null { + if (!connector) return null; + + // ---- Embedding (OpenAI, USD) ---- + const EMBED_USD_PER_M = 0.02; + const tokensPerMail: Record = { metadata: 30, snippet: 120, full: 500 }; + const depth = prefs.mailContentDepth ?? 'full'; + const maxAge = prefs.maxAgeDays ?? 90; + const mailCount = Math.min(500, Math.round((maxAge / 90) * 500)); + const taskCount = Math.min(500, Math.round((maxAge / 90) * 300)); + + let embedLowTokens = 0; + let embedHighTokens = 0; + + if (connector === 'google' || connector === 'msft') { + const mailTokens = mailCount * tokensPerMail[depth]; + embedLowTokens += mailTokens * 0.6; + embedHighTokens += mailTokens * 1.5 + 500_000; // Drive/SharePoint + if (prefs.mailIndexAttachments) embedHighTokens += 200_000; + } else if (connector === 'clickup') { + const scope = prefs.clickupScope ?? 'title_description'; + const tpt = scope === 'titles' ? 30 : scope === 'title_description' ? 200 : 400; + embedLowTokens += taskCount * tpt * 0.6; + embedHighTokens += taskCount * tpt * 1.5; + } + + const fmtUsd = (tokens: number) => { + const usd = (tokens / 1_000_000) * EMBED_USD_PER_M; + if (usd < 0.001) return '< 0.01 $'; + if (usd < 0.10) return `~${usd.toFixed(3)} $`; + return `~${usd.toFixed(2)} $`; + }; + + // ---- Neutralization (Private LLM, CHF 0.01/call) ---- + // Each item (email / task / file) = 1 LLM call for short content, + // 2-4 for long threads/documents. + const NEUT_CHF_PER_CALL = 0.01; + let neutLow: string | null = null; + let neutHigh: string | null = null; + + if (prefs.neutralizeBeforeEmbed) { + let lowCalls = 0; + let highCalls = 0; + + if (connector === 'google' || connector === 'msft') { + lowCalls += mailCount * 1; // 1 call / short email + highCalls += mailCount * 3; // up to 3 calls / long thread + lowCalls += 20; // Drive/SharePoint files (low) + highCalls += 200; // Drive/SharePoint files (high, large PDFs) + } else if (connector === 'clickup') { + lowCalls += taskCount * 1; + highCalls += taskCount * 2; + } + + const fmtChf = (calls: number) => { + const chf = calls * NEUT_CHF_PER_CALL; + if (chf < 0.01) return '< 0.01 CHF'; + return `~${chf.toFixed(2)} CHF`; + }; + + neutLow = fmtChf(lowCalls); + neutHigh = fmtChf(highCalls); + } + + return { + embeddingLow: fmtUsd(embedLowTokens), + embeddingHigh: fmtUsd(embedHighTokens), + neutralizationLow: neutLow, + neutralizationHigh: neutHigh, + note: 'Einmalig beim ersten Sync. Folge-Syncs kosten weniger (nur neue Inhalte).', + }; +} + +// --------------------------------------------------------------------------- +// Props +// --------------------------------------------------------------------------- + +interface AddConnectionWizardProps { + open: boolean; + onClose: () => void; + onConnect: ( + type: ConnectorType, + knowledgeEnabled: boolean, + prefs: KnowledgePreferences | null, + ) => Promise; + isConnecting?: boolean; +} + +// --------------------------------------------------------------------------- +// Component +// --------------------------------------------------------------------------- + +export const AddConnectionWizard: React.FC = ({ + open, + onClose, + onConnect, + isConnecting = false, +}) => { + const [state, setState] = useState({ + step: 0, + connector: null, + knowledgeEnabled: false, + prefs: { ...DEFAULT_PREFS }, + }); + + const reset = () => + setState({ step: 0, connector: null, knowledgeEnabled: false, prefs: { ...DEFAULT_PREFS } }); + + const handleClose = () => { + reset(); + onClose(); + }; + + const setStep = (step: WizardState['step']) => setState(s => ({ ...s, step })); + const setConnector = (connector: ConnectorType) => + setState(s => ({ ...s, connector, step: 1 })); + const setKnowledgeEnabled = (v: boolean) => + setState(s => ({ ...s, knowledgeEnabled: v, step: v ? 2 : 3 })); + const updatePref = (key: K, value: KnowledgePreferences[K]) => + setState(s => ({ ...s, prefs: { ...s.prefs, [key]: value } })); + + const handleConnect = async () => { + if (!state.connector) return; + await onConnect( + state.connector, + state.knowledgeEnabled, + state.knowledgeEnabled ? state.prefs : null, + ); + reset(); + onClose(); + }; + + const STEP_LABELS = ['Anbieter', 'Zustimmung', 'Einstellungen', 'Übersicht']; + const visibleSteps = state.knowledgeEnabled + ? [0, 1, 2, 3] + : [0, 1, 3]; + + return ( + + {/* Stepper */} +
+ {[0, 1, 2, 3].map(i => ( +
i ? styles.stepDotDone : '', + !visibleSteps.includes(i) ? styles.stepDotHidden : '', + ].join(' ')} + > + {state.step > i ? : i + 1} +
+ ))} +
+ +
+ {/* ---- Step 0: Connector ---- */} + {state.step === 0 && ( +
+

Anbieter wählen

+

Welchen Dienst möchtest du verbinden?

+
+ {(['google', 'msft', 'clickup'] as ConnectorType[]).map(type => ( + + ))} +
+
+ )} + + {/* ---- Step 1: Consent ---- */} + {state.step === 1 && ( +
+
+

Wissensdatenbank

+

+ Möchtest du Inhalte aus dieser Verbindung in deine persönliche + Wissensdatenbank aufnehmen, damit die KI beim Antworten auf Informationen + aus{' '} + {state.connector ? CONNECTOR_LABELS[state.connector] : 'diesem Dienst'}{' '} + zurückgreifen kann? +

+

+ Du kannst diese Einstellung später in den Verbindungsdetails ändern. +

+
+ + +
+
+ +
+
+ )} + + {/* ---- Step 2: Preferences ---- */} + {state.step === 2 && ( +
+

Einstellungen

+

+ Steuere, welche Inhalte und in welcher Form sie indexiert werden. +

+ +
+ +

+ Persönliche Daten (Namen, E-Mail-Adressen) werden vor dem Speichern ersetzt. +

+
+ + {(state.connector === 'google' || state.connector === 'msft') && ( + <> +
+ +
+
+ +
+ + )} + + {state.connector === 'clickup' && ( +
+ +
+ )} + +
+ +

0 = kein Limit

+
+ +
+ + +
+
+ )} + + {/* ---- Step 3: Summary ---- */} + {state.step === 3 && ( +
+

Zusammenfassung

+
+
+ Anbieter + + {CONNECTOR_ICONS[state.connector!]}  + {state.connector ? CONNECTOR_LABELS[state.connector] : '—'} + +
+
+ Wissensdatenbank + + {state.knowledgeEnabled ? '✓ Aktiv' : '✗ Nicht aktiv'} + +
+ {state.knowledgeEnabled && ( + <> +
+ Anonymisierung + + {state.prefs.neutralizeBeforeEmbed ? 'Ja' : 'Nein'} + +
+ {(state.connector === 'google' || state.connector === 'msft') && ( +
+ E-Mail-Tiefe + + {{ metadata: 'Nur Metadaten', snippet: 'Vorschautext', full: 'Volltext' }[ + state.prefs.mailContentDepth ?? 'full' + ] ?? state.prefs.mailContentDepth} + +
+ )} + {state.connector === 'clickup' && ( +
+ Aufgaben-Inhalt + + {{ + titles: 'Nur Titel', + title_description: 'Titel + Beschreibung', + with_comments: 'Titel + Beschreibung + Kommentare', + }[state.prefs.clickupScope ?? 'title_description'] ?? state.prefs.clickupScope} + +
+ )} +
+ Zeitfenster + + {state.prefs.maxAgeDays ? `${state.prefs.maxAgeDays} Tage` : 'Unbegrenzt'} + +
+ + )} +
+ + {/* Cost estimate — only shown when knowledge ingestion is enabled */} + {state.knowledgeEnabled && (() => { + const est = computeCostEstimate(state.connector, state.prefs); + if (!est) return null; + return ( +
+ +
+ Geschätzte Kosten (erster Sync) + + + + + + + {est.neutralizationLow && ( + + + + + )} + +
Embedding + {est.embeddingLow} – {est.embeddingHigh} +
Anonymisierung (Private LLM) + {est.neutralizationLow} – {est.neutralizationHigh} +
+ {est.neutralizationLow && ( + + ⚠ Anonymisierung ist der Hauptkostentreiber (CHF 0.01 pro LLM-Aufruf, on-premise). + + )} + {est.note} +
+
+ ); + })()} + +
+ + +
+
+ )} +
+
+ ); +}; + +export default AddConnectionWizard; diff --git a/src/hooks/useConnections.ts b/src/hooks/useConnections.ts index ad1f308..670bca7 100644 --- a/src/hooks/useConnections.ts +++ b/src/hooks/useConnections.ts @@ -708,6 +708,90 @@ export function useConnections() { } }, [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', + knowledgeIngestionEnabled: boolean, + knowledgePreferences?: import('../api/connectionApi').KnowledgePreferences | null, + ): Promise => { + if (isConnecting) return; + 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((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 @@ -726,6 +810,7 @@ export function useConnections() { createClickupConnectionAndAuth, createInfomaniakConnection, submitInfomaniakToken, + createConnectionAndAuth, isLoading, loading: isLoading, // Alias for FormGenerator compatibility isConnecting, diff --git a/src/pages/basedata/ConnectionsPage.module.css b/src/pages/basedata/ConnectionsPage.module.css new file mode 100644 index 0000000..0d94c59 --- /dev/null +++ b/src/pages/basedata/ConnectionsPage.module.css @@ -0,0 +1,90 @@ +/* ConnectionsPage — supplemental styles for sync banner */ + +.syncBanner { + display: flex; + align-items: flex-start; + gap: 0.75rem; + padding: 0.875rem 1rem 0.875rem 1.125rem; + margin: 0 0 1rem; + background: linear-gradient(135deg, #fffbeb, #fef3c7); + border: 1px solid #fcd34d; + border-radius: 8px; + box-shadow: 0 1px 4px rgba(0, 0, 0, 0.06); + animation: slidein 0.25s ease; +} + +@keyframes slidein { + from { opacity: 0; transform: translateY(-6px); } + to { opacity: 1; transform: translateY(0); } +} + +.syncSpinner { + flex-shrink: 0; + margin-top: 3px; + color: #d97706; + font-size: 1rem; + animation: spin 1.4s linear infinite; +} + +@keyframes spin { + to { transform: rotate(360deg); } +} + +.syncText { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.syncTitle { + font-weight: 600; + font-size: 0.9375rem; + color: #92400e; +} + +.syncDetail { + font-size: 0.8125rem; + color: #78350f; + line-height: 1.5; +} + +.syncDismiss { + flex-shrink: 0; + background: none; + border: none; + cursor: pointer; + color: #b45309; + padding: 2px 4px; + border-radius: 4px; + font-size: 0.875rem; + display: flex; + align-items: center; + transition: background 0.15s; +} + +.syncDismiss:hover { + background: rgba(0, 0, 0, 0.06); +} + +/* Dark theme */ +:global(.dark-theme) .syncBanner { + background: rgba(251, 191, 36, 0.08); + border-color: rgba(251, 191, 36, 0.3); +} + +:global(.dark-theme) .syncTitle { + color: #fcd34d; +} + +:global(.dark-theme) .syncDetail { + color: #fde68a; +} + +:global(.dark-theme) .syncDismiss { + color: #fbbf24; +} + +:global(.dark-theme) .syncSpinner { + color: #fbbf24; +} diff --git a/src/pages/basedata/ConnectionsPage.tsx b/src/pages/basedata/ConnectionsPage.tsx index 24d4fa1..a1ad7ba 100644 --- a/src/pages/basedata/ConnectionsPage.tsx +++ b/src/pages/basedata/ConnectionsPage.tsx @@ -5,17 +5,22 @@ * Follows the pattern established in AdminUsersPage/WorkflowsPage. */ -import React, { useState, useMemo, useEffect } from 'react'; +import React, { useState, useMemo, useEffect, useRef } 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 { FaSync, FaLink, FaRedo, FaShieldAlt, FaPlus, FaSpinner, FaTimes, FaSyncAlt, FaCloud } from 'react-icons/fa'; import { getApiBaseUrl } from '../../../config/config'; import styles from '../admin/Admin.module.css'; - +import bannerStyles from './ConnectionsPage.module.css'; +import { AddConnectionWizard } from '../../components/AddConnectionWizard/AddConnectionWizard'; +import type { ConnectorType } from '../../components/AddConnectionWizard/AddConnectionWizard'; +import type { KnowledgePreferences } from '../../api/connectionApi'; import { useLanguage } from '../../providers/language/LanguageContext'; import { resolveColumnTypes } from '../../utils/columnTypeResolver'; +const SYNC_BANNER_TTL_MS = 10 * 60 * 1000; // 10 minutes — conservative upper bound for bootstrap + export const ConnectionsPage: React.FC = () => { const { t } = useLanguage(); @@ -32,9 +37,7 @@ export const ConnectionsPage: React.FC = () => { updateOptimistically, deleteConnection, handleInlineUpdate, - createGoogleConnectionAndAuth, - createMicrosoftConnectionAndAuth, - createClickupConnectionAndAuth, + createConnectionAndAuth, createInfomaniakConnection, submitInfomaniakToken, connectWithPopup, @@ -48,6 +51,23 @@ export const ConnectionsPage: React.FC = () => { const [refreshingConnections, setRefreshingConnections] = useState>(new Set()); const [reconnectingConnections, setReconnectingConnections] = useState>(new Set()); const [adminConsentPending, setAdminConsentPending] = useState(false); + const [wizardOpen, setWizardOpen] = useState(false); + // Banner shown while knowledge bootstrap is running in the background + const [syncBanner, setSyncBanner] = useState<{ + connector: string; + startedAt: number; + } | null>(null); + const syncBannerTimer = useRef | null>(null); + + const showSyncBanner = (connector: string) => { + if (syncBannerTimer.current) clearTimeout(syncBannerTimer.current); + setSyncBanner({ connector, startedAt: Date.now() }); + syncBannerTimer.current = setTimeout(() => setSyncBanner(null), SYNC_BANNER_TTL_MS); + }; + const dismissSyncBanner = () => { + if (syncBannerTimer.current) clearTimeout(syncBannerTimer.current); + setSyncBanner(null); + }; // 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). @@ -201,35 +221,20 @@ export const ConnectionsPage: React.FC = () => { } }; - // 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; + const handleWizardConnect = async ( + type: ConnectorType, + knowledgeEnabled: boolean, + knowledgePreferences: KnowledgePreferences | null, + ) => { try { - await createGoogleConnectionAndAuth(); + await createConnectionAndAuth(type, knowledgeEnabled, knowledgePreferences); refetch(); + if (knowledgeEnabled) { + const LABELS: Record = { google: 'Google', msft: 'Microsoft 365', clickup: 'ClickUp' }; + showSyncBanner(LABELS[type] ?? type); + } } 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); + console.error('Error creating connection via wizard:', error); } }; @@ -356,28 +361,13 @@ export const ConnectionsPage: React.FC = () => { {canCreate && ( <> - - + + )} +
{
)} + + setWizardOpen(false)} + onConnect={handleWizardConnect} + isConnecting={isConnecting} + /> ); };