diff --git a/docsgpt/agents/tool_executor.py b/docsgpt/agents/tool_executor.py index 2e9faf9f..181612ae 100644 --- a/docsgpt/agents/tool_executor.py +++ b/docsgpt/agents/tool_executor.py @@ -1649,7 +1649,7 @@ class ToolExecutor: # Keys the client needs that are not part of the fixed shape below. They are # small and optional, and are copied only when present so an ordinary tool # call does not grow null columns in every persisted row. - _PRESERVED_TOOL_CALL_KEYS = ("artifacts", "device_id") + _PRESERVED_TOOL_CALL_KEYS = ("artifacts", "device_id", "connector_key", "connector_name", "access") def get_truncated_tool_calls(self) -> List[Dict]: """Project tool calls into the shape that is streamed and persisted. diff --git a/docsgpt/alembic/versions/0038_connections.py b/docsgpt/alembic/versions/0038_connections.py index 42586c75..b9da954d 100644 --- a/docsgpt/alembic/versions/0038_connections.py +++ b/docsgpt/alembic/versions/0038_connections.py @@ -347,13 +347,29 @@ def _link_api_key_tools(bind) -> None: def _downgrade_credentials() -> None: """Decrypt the envelopes back into the pre-0038 plaintext columns.""" + + from sqlalchemy import text + + + bind = op.get_bind() + has_envelope = bind.execute( + text( + "SELECT 1 FROM information_schema.columns " + "WHERE table_name = 'connector_sessions' AND column_name = 'encrypted_credentials'" + ) + ).first() + if has_envelope is not None: + _decrypt_back(bind) + _restore_account_index() + + +def _decrypt_back(bind) -> None: import json from sqlalchemy import text from docsgpt.security.encryption import CredentialDecryptionError, decrypt_json - bind = op.get_bind() # Tools keep their v1 secrets; connections made only for them go away. bind.execute(text("UPDATE user_tools SET connection_id = NULL WHERE connection_id IN " "(SELECT id FROM connector_sessions WHERE auth_kind = 'api_key')")) @@ -384,6 +400,9 @@ def _downgrade_credentials() -> None: "id": row.id, }, ) + + +def _restore_account_index() -> None: # Several accounts per provider cannot survive the old unique index: keep # the most recently updated one. op.execute( diff --git a/frontend/DESIGN.md b/frontend/DESIGN.md index 3584eab5..1298a479 100644 --- a/frontend/DESIGN.md +++ b/frontend/DESIGN.md @@ -600,8 +600,9 @@ overlay. The app has one `ToastViewport`, mounted in `App.tsx`; it is the live region (`role="status"`, `aria-live="polite"`) and the fixed stack, so `Toast` cards carry no role and no toast renders its own rail or positioning. Top -to bottom it holds `TeamNotificationToast`, `ToolApprovalToast`, -`UploadToast` and `ActionToast`, and it moves to the bottom-left while the +to bottom it holds `TeamNotificationToast`, `ConnectionHealthToast` (a +connection that needs reconnecting, with a Reconnect action), +`ToolApprovalToast`, `UploadToast` and `ActionToast`, and it moves to the bottom-left while the workflow Preview drawer is open. A new toast component returns only its `Toast` cards and is added to that viewport. A page that reports the result of an action (the admin Users actions) dispatches diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 20f2ed11..65d84787 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -43,6 +43,7 @@ import Notification from './components/Notification'; import ToolApprovalToast from './notifications/ToolApprovalToast'; import TeamNotificationToast from './notifications/TeamNotificationToast'; import ActionToast from './notifications/ActionToast'; +import ConnectionHealthToast from './notifications/ConnectionHealthToast'; function AuthWrapper({ children }: { children: React.ReactNode }) { const { t } = useTranslation(); @@ -128,6 +129,7 @@ function MainLayout() { onMouseDown={(e) => e.stopPropagation()} > + diff --git a/frontend/src/api/endpoints.ts b/frontend/src/api/endpoints.ts index d621dfea..65a5cb42 100644 --- a/frontend/src/api/endpoints.ts +++ b/frontend/src/api/endpoints.ts @@ -82,8 +82,10 @@ const endpoints = { DELETE_TOOL: '/api/delete_tool', PARSE_SPEC: '/api/parse_spec', SYNC_CONNECTOR: '/api/connectors/sync', - CONNECTOR_AUTH: (provider: string) => - `/api/connectors/auth?provider=${provider}`, + CONNECTOR_AUTH: (provider: string, connectionId?: string) => + `/api/connectors/auth?provider=${encodeURIComponent(provider)}${ + connectionId ? `&connection_id=${encodeURIComponent(connectionId)}` : '' + }`, CONNECTOR_FILES: '/api/connectors/files', CONNECTOR_VALIDATE_SESSION: '/api/connectors/validate-session', CONNECTOR_DISCONNECT: '/api/connectors/disconnect', @@ -92,6 +94,17 @@ const endpoints = { CONNECTION: (id: string) => `/api/connections/${encodeURIComponent(id)}`, CONNECTION_DISCONNECT: (id: string) => `/api/connections/${encodeURIComponent(id)}/disconnect`, + CONNECTION_SETUP: (id: string) => + `/api/connections/${encodeURIComponent(id)}/setup`, + CONNECTION_RECONNECT: (id: string) => + `/api/connections/${encodeURIComponent(id)}/reconnect`, + CONNECTION_PICKER_TOKEN: (id: string) => + `/api/connections/${encodeURIComponent(id)}/picker-token`, + CONNECTION_REFRESH_TOOLS: (id: string) => + `/api/connections/${encodeURIComponent(id)}/refresh-tools`, + CONNECTION_TOOL_PERMISSIONS: (id: string, toolId: string) => + `/api/connections/${encodeURIComponent(id)}/tools/${encodeURIComponent(toolId)}/permissions`, + CONNECTIONS_CLAIM: '/api/connections/claim', GET_CHUNKS: ( docId: string, page: number, diff --git a/frontend/src/api/services/connectorsService.ts b/frontend/src/api/services/connectorsService.ts index 19640996..985122bc 100644 --- a/frontend/src/api/services/connectorsService.ts +++ b/frontend/src/api/services/connectorsService.ts @@ -11,6 +11,21 @@ const json = async (response: Response) => { } }; +export type ConnectionSetupBody = { + create_tools?: boolean; + tool_permissions?: Record; + sync?: { + items: Record; + frequency?: string; + name?: string; + }; +}; + +export type RemoveConnectionBody = { + sources: 'keep' | 'delete'; + tools: 'keep' | 'delete'; +}; + const connectorsService = { getCatalog: async (token: string | null) => json( @@ -20,10 +35,86 @@ const connectorsService = { json(await throttledApiClient.get(endpoints.USER.CONNECTIONS, token)), getConnection: async (id: string, token: string | null) => json(await apiClient.get(endpoints.USER.CONNECTION(id), token)), + createConnection: async ( + body: { + connector_key: string; + credentials: Record; + label?: string; + }, + token: string | null, + ) => json(await apiClient.post(endpoints.USER.CONNECTIONS, body, token)), + setup: async ( + id: string, + body: ConnectionSetupBody, + token: string | null, + idempotencyKey?: string, + ) => + json( + await apiClient.post( + endpoints.USER.CONNECTION_SETUP(id), + body, + token, + idempotencyKey ? { 'Idempotency-Key': idempotencyKey } : {}, + ), + ), + reconnect: async ( + id: string, + body: { credentials?: Record }, + token: string | null, + ) => + json( + await apiClient.post( + endpoints.USER.CONNECTION_RECONNECT(id), + body, + token, + ), + ), disconnect: async (id: string, token: string | null) => json( await apiClient.post(endpoints.USER.CONNECTION_DISCONNECT(id), {}, token), ), + remove: async ( + id: string, + body: RemoveConnectionBody, + token: string | null, + ) => json(await apiClient.delete(endpoints.USER.CONNECTION(id), token, body)), + pickerToken: async (id: string, token: string | null) => + json( + await apiClient.post( + endpoints.USER.CONNECTION_PICKER_TOKEN(id), + {}, + token, + ), + ), + refreshTools: async (id: string, token: string | null) => + json( + await apiClient.post( + endpoints.USER.CONNECTION_REFRESH_TOOLS(id), + {}, + token, + ), + ), + setToolPermissions: async ( + id: string, + toolId: string, + permissions: Record, + token: string | null, + ) => + json( + await apiClient.put( + endpoints.USER.CONNECTION_TOOL_PERMISSIONS(id, toolId), + { permissions }, + token, + ), + ), + claim: async (provider: string, sessionToken: string, token: string | null) => + json( + await apiClient.post( + endpoints.USER.CONNECTIONS_CLAIM, + { provider, session_token: sessionToken }, + token, + ), + ), }; export default connectorsService; diff --git a/frontend/src/api/services/userService.ts b/frontend/src/api/services/userService.ts index 85253b01..934ec890 100644 --- a/frontend/src/api/services/userService.ts +++ b/frontend/src/api/services/userService.ts @@ -1,4 +1,3 @@ -import { getSessionToken } from '../../utils/providerUtils'; import apiClient, { throttledApiClient } from '../client'; import endpoints from '../endpoints'; @@ -277,24 +276,15 @@ const userService = { apiClient.post(endpoints.USER.MCP_SAVE_SERVER, data, token), getMCPAuthStatus: (token: string | null): Promise => throttledApiClient.get(endpoints.USER.MCP_AUTH_STATUS, token), - syncConnector: ( - docId: string, + // The source's own connection syncs it; no browser token is involved. + syncConnector: (docId: string, token: string | null): Promise => + apiClient.post(endpoints.USER.SYNC_CONNECTOR, { source_id: docId }, token), + getConnectorAuthUrl: ( provider: string, token: string | null, - ): Promise => { - const sessionToken = getSessionToken(provider); - return apiClient.post( - endpoints.USER.SYNC_CONNECTOR, - { - source_id: docId, - session_token: sessionToken, - provider: provider, - }, - token, - ); - }, - getConnectorAuthUrl: (provider: string, token: string | null): Promise => - apiClient.get(endpoints.USER.CONNECTOR_AUTH(provider), token), + connectionId?: string, + ): Promise => + apiClient.get(endpoints.USER.CONNECTOR_AUTH(provider, connectionId), token), getConnectorFiles: ( data: any, token: string | null, @@ -307,28 +297,6 @@ const userService = { {}, signal, ), - validateConnectorSession: ( - provider: string, - token: string | null, - ): Promise => - apiClient.post( - endpoints.USER.CONNECTOR_VALIDATE_SESSION, - { - provider, - session_token: getSessionToken(provider), - }, - token, - ), - disconnectConnector: ( - provider: string, - sessionToken: string, - token: string | null, - ): Promise => - apiClient.post( - endpoints.USER.CONNECTOR_DISCONNECT, - { provider, session_token: sessionToken }, - token, - ), textToSpeech: ( text: string, token: string | null, diff --git a/frontend/src/components/ConnectorAuth.tsx b/frontend/src/components/ConnectorAuth.tsx index 0f25a5d1..2fad975a 100644 --- a/frontend/src/components/ConnectorAuth.tsx +++ b/frontend/src/components/ConnectorAuth.tsx @@ -11,7 +11,9 @@ import { Button } from './ui/button'; interface ConnectorAuthProps { provider: string; - onSuccess: (data: { session_token: string; user_email: string }) => void; + /** Reconnect this connection (the same account) rather than add one. */ + connectionId?: string; + onSuccess: (data: { connection_id: string; user_email: string }) => void; onError: (error: string) => void; label?: string; isConnected?: boolean; @@ -22,6 +24,7 @@ interface ConnectorAuthProps { const ConnectorAuth: React.FC = ({ provider, + connectionId, onSuccess, onError, label, @@ -76,7 +79,7 @@ const ConnectorAuth: React.FC = ({ cleanup(); authWindowRef.current = null; onSuccess({ - session_token: event.data.session_token, + connection_id: event.data.connection_id, user_email: event.data.user_email || t('modals.uploadDoc.connectors.auth.connectedUser'), @@ -120,6 +123,7 @@ const ConnectorAuth: React.FC = ({ const authResponse = await userService.getConnectorAuthUrl( provider, token, + connectionId, ); if (!mountedRef.current) { authWindow.close(); diff --git a/frontend/src/components/ConnectorTree.tsx b/frontend/src/components/ConnectorTree.tsx index 53538189..0d0ae49a 100644 --- a/frontend/src/components/ConnectorTree.tsx +++ b/frontend/src/components/ConnectorTree.tsx @@ -49,7 +49,7 @@ const ConnectorTree: React.FC = ({ setSyncProgress(0); try { - const response = await userService.syncConnector(docId, provider, token); + const response = await userService.syncConnector(docId, token); const data = await response.json(); if (data.success) { diff --git a/frontend/src/components/FilePicker.test.tsx b/frontend/src/components/FilePicker.test.tsx index 6ac53d29..0fb425d9 100644 --- a/frontend/src/components/FilePicker.test.tsx +++ b/frontend/src/components/FilePicker.test.tsx @@ -1,21 +1,26 @@ +import { configureStore } from '@reduxjs/toolkit'; import { act } from 'react'; import { createRoot, type Root } from 'react-dom/client'; +import { Provider } from 'react-redux'; -const validateConnectorSession = vi.fn(); const getConnectorFiles = vi.fn(); vi.mock('../api/services/userService', () => ({ default: { - validateConnectorSession: (...args: unknown[]) => - validateConnectorSession(...args), getConnectorFiles: (...args: unknown[]) => getConnectorFiles(...args), - disconnectConnector: vi.fn(), }, })); -vi.mock('../utils/providerUtils', () => ({ - getSessionToken: () => 'session-token', - setSessionToken: vi.fn(), - removeSessionToken: vi.fn(), +const pickerToken = vi.fn(); +const disconnect = vi.fn(); +vi.mock('../api/services/connectorsService', () => ({ + default: { + pickerToken: (...args: unknown[]) => pickerToken(...args), + disconnect: (...args: unknown[]) => disconnect(...args), + getCatalog: vi.fn().mockResolvedValue({ success: true, connectors: [] }), + listConnections: vi + .fn() + .mockResolvedValue({ success: true, connections: [] }), + }, })); vi.mock('../components/ConnectorAuth', () => ({ default: () => null })); @@ -24,17 +29,38 @@ vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key: string) => key }), })); +import connectorsReducer from '../connectors/connectorsSlice'; +import type { Connection } from '../connectors/types'; import { FilePicker } from './FilePicker'; Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); +const connection = (overrides: Partial = {}): Connection => ({ + id: 'conn-1', + connector_key: 'share_point', + name: 'SharePoint', + display_name: 'SharePoint', + icon: 'sharepoint', + account_label: 'lena@meridian.example', + auth_kind: 'oauth', + status: 'connected', + server_url: null, + last_error: null, + created_at: null, + updated_at: null, + last_used_at: null, + source_count: 0, + tool_count: 0, + ...overrides, +}); + describe('FilePicker', () => { let container: HTMLDivElement; let root: Root; beforeEach(() => { - validateConnectorSession.mockReset(); getConnectorFiles.mockReset(); + pickerToken.mockReset(); container = document.createElement('div'); document.body.appendChild(container); root = createRoot(container); @@ -45,43 +71,70 @@ describe('FilePicker', () => { container.remove(); }); - it('shows an expired session as a destructive alert', async () => { - validateConnectorSession.mockResolvedValue({ ok: false }); + const render = async (provider: string, connections: Connection[]) => { + const store = configureStore({ + reducer: { + connectors: connectorsReducer, + preference: (state = { token: null }) => state, + }, + preloadedState: { + connectors: { + enabled: true, + catalog: [], + connections, + loading: false, + loaded: true, + failed: false, + }, + preference: { token: null }, + }, + }); await act(async () => { root.render( - undefined} - />, + + undefined} + /> + , ); }); + }; + + it('shows a connection that needs signing in as a destructive alert', async () => { + getConnectorFiles.mockResolvedValue({ + json: async () => ({ success: false, reconnect: true }), + }); + await render('google_drive', [ + connection({ connector_key: 'google_drive' }), + ]); const alert = container.querySelector('[role="alert"]'); expect(alert?.textContent).toContain('filePicker.sessionExpiredFor'); expect(alert?.querySelector('svg')).not.toBeNull(); }); + it('lists files with the connection id, never a session token', async () => { + getConnectorFiles.mockResolvedValue({ + json: async () => ({ success: true, files: [], next_page_token: null }), + }); + await render('google_drive', [ + connection({ connector_key: 'google_drive' }), + ]); + const body = getConnectorFiles.mock.calls[0][0]; + expect(body.connection_id).toBe('conn-1'); + expect(body).not.toHaveProperty('session_token'); + }); + async function renderSharePoint() { - validateConnectorSession.mockResolvedValue({ - ok: true, - json: async () => ({ - success: true, - user_email: 'lena@meridian.example', - allows_shared_content: true, - }), + pickerToken.mockResolvedValue({ + success: true, + allows_shared_content: true, }); getConnectorFiles.mockResolvedValue({ json: async () => ({ success: true, files: [], next_page_token: null }), }); - await act(async () => { - root.render( - undefined} - />, - ); - }); + await render('share_point', [connection()]); } it('renders the drive switch as underline tabs with the active one marked', async () => { @@ -112,4 +165,22 @@ describe('FilePicker', () => { expect(page?.getAttribute('title')).toBe('filePicker.myFiles'); expect(trail?.querySelector('button[disabled]')).toBeNull(); }); + + it('offers an account switch when several accounts are connected', async () => { + getConnectorFiles.mockResolvedValue({ + json: async () => ({ success: true, files: [], next_page_token: null }), + }); + pickerToken.mockResolvedValue({ success: true }); + await render('share_point', [ + connection(), + connection({ id: 'conn-2', account_label: 'ops@meridian.example' }), + ]); + expect(container.textContent).toContain('filePicker.account'); + }); + + it('shows nothing to browse without a connection', async () => { + await render('confluence', []); + expect(getConnectorFiles).not.toHaveBeenCalled(); + expect(container.querySelector('[data-slot="tabs-list"]')).toBeNull(); + }); }); diff --git a/frontend/src/components/FilePicker.tsx b/frontend/src/components/FilePicker.tsx index 1e0d1ac1..6f191bcd 100644 --- a/frontend/src/components/FilePicker.tsx +++ b/frontend/src/components/FilePicker.tsx @@ -10,12 +10,22 @@ import { useTranslation } from 'react-i18next'; import userService from '../api/services/userService'; import { formatBytes } from '../utils/stringUtils'; import { formatDateTime } from '../utils/dateTimeUtils'; -import { - getSessionToken, - setSessionToken, - removeSessionToken, -} from '../utils/providerUtils'; +import { useDispatch, useSelector } from 'react-redux'; +import connectorsService from '../api/services/connectorsService'; import ConnectorAuth from '../components/ConnectorAuth'; +import { + loadConnectors, + selectConnections, +} from '../connectors/connectorsSlice'; +import type { AppDispatch } from '../store'; +import { FormField } from './ui/form-field'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from './ui/select'; import SearchInput from './SearchInput'; import { Alert, AlertDescription } from './ui/alert'; import { @@ -77,6 +87,13 @@ interface CloudFilePickerProps { onDisconnect?: () => void; /** Called with the first item's name when the selection goes from empty to one. */ onFirstPickName?: (name: string) => void; + /** + * The connection (signed-in account) to browse. Left out, the picker uses + * the first connected account for ``provider`` and lets the user switch. + */ + connectionId?: string | null; + /** Reports the account the picker is browsing, so the upload can name it. */ + onConnectionChange?: (connectionId: string | null) => void; provider: string; token: string | null; initialSelectedFiles?: string[]; @@ -87,11 +104,14 @@ export const FilePicker: React.FC = ({ onSelectionChange, onDisconnect, onFirstPickName, + connectionId: controlledConnectionId, + onConnectionChange, provider, token, initialSelectedFiles = [], }) => { const { t } = useTranslation(); + const dispatch = useDispatch(); const PROVIDER_CONFIG = { google_drive: { displayName: 'Drive', @@ -116,6 +136,23 @@ export const FilePicker: React.FC = ({ ); }; + const allConnections = useSelector(selectConnections); + const accounts = allConnections.filter( + (connection) => + connection.connector_key === provider && + connection.status === 'connected', + ); + const [chosenConnectionId, setChosenConnectionId] = useState( + null, + ); + const activeConnectionId = + controlledConnectionId ?? + (chosenConnectionId && + accounts.some((account) => account.id === chosenConnectionId) + ? chosenConnectionId + : (accounts[0]?.id ?? null)); + const activeAccount = accounts.find((a) => a.id === activeConnectionId); + const [files, setFiles] = useState([]); const [selectedFiles, setSelectedFiles] = useState(initialSelectedFiles); @@ -134,14 +171,18 @@ export const FilePicker: React.FC = ({ ]); const [searchQuery, setSearchQuery] = useState(''); const [authError, setAuthError] = useState(''); - const [isConnected, setIsConnected] = useState(false); - const [userEmail, setUserEmail] = useState(''); const [allowsSharedContent, setAllowsSharedContent] = useState(false); const [activeTab, setActiveTab] = useState<'my_files' | 'shared'>('my_files'); + const isConnected = !!activeConnectionId; + const scrollContainerRef = useRef(null); const abortControllerRef = useRef(null); + useEffect(() => { + onConnectionChange?.(activeConnectionId); + }, [activeConnectionId]); + const isFolder = (file: CloudFile) => { return ( file.isFolder || @@ -152,7 +193,7 @@ export const FilePicker: React.FC = ({ const loadCloudFiles = useCallback( async ( - sessionToken: string, + connectionId: string, folderId: string | null, pageToken?: string, searchQuery = '', @@ -172,7 +213,7 @@ export const FilePicker: React.FC = ({ try { const body: Record = { provider: provider, - session_token: sessionToken, + connection_id: connectionId, folder_id: folderId, limit: 10, page_token: pageToken, @@ -193,7 +234,14 @@ export const FilePicker: React.FC = ({ setNextPageToken(data.next_page_token); setHasMoreFiles(!!data.next_page_token); } else { - console.error('Error loading files:', data.error); + if (data.reconnect) { + setAuthError( + t('filePicker.sessionExpiredFor', { + provider: getProviderConfig(provider).displayName, + }), + ); + dispatch(loadConnectors({ token })); + } if (!pageToken) { setFiles([]); } @@ -213,72 +261,26 @@ export const FilePicker: React.FC = ({ [token, provider], ); - const validateAndLoadFiles = useCallback(async () => { - const sessionToken = getSessionToken(provider); - if (!sessionToken) { - setIsConnected(false); - return; - } - - try { - const validateResponse = await userService.validateConnectorSession( - provider, - token, - ); - - if (!validateResponse.ok) { - removeSessionToken(provider); - setIsConnected(false); - setAuthError( - t('filePicker.sessionExpiredFor', { - provider: getProviderConfig(provider).displayName, - }), - ); - return; - } - - const validateData = await validateResponse.json(); - if (validateData.success) { - setUserEmail( - validateData.user_email || - t('modals.uploadDoc.connectors.auth.connectedUser'), - ); - setIsConnected(true); - setAuthError(''); - if (provider === 'share_point') { - setAllowsSharedContent(validateData.allows_shared_content ?? false); - } - - setFiles([]); - setNextPageToken(null); - setHasMoreFiles(false); - setCurrentFolderId(null); - setActiveTab('my_files'); - setFolderPath([ - { - id: null, - name: getProviderConfig(provider).rootName, - }, - ]); - loadCloudFiles(sessionToken, null, undefined, ''); - } else { - removeSessionToken(provider); - setIsConnected(false); - setAuthError( - validateData.error || - t('modals.uploadDoc.connectors.googleDrive.sessionExpiredGeneric'), - ); - } - } catch (error) { - console.error('Error validating session:', error); - setAuthError(t('modals.uploadDoc.connectors.googleDrive.validateFailed')); - setIsConnected(false); - } - }, [provider, token, loadCloudFiles]); - + // Browse from the root whenever the account changes. useEffect(() => { - validateAndLoadFiles(); - }, [validateAndLoadFiles]); + setFiles([]); + setNextPageToken(null); + setHasMoreFiles(false); + setCurrentFolderId(null); + setActiveTab('my_files'); + setAllowsSharedContent(false); + setFolderPath([{ id: null, name: getProviderConfig(provider).rootName }]); + if (!activeConnectionId) return; + setAuthError(''); + loadCloudFiles(activeConnectionId, null, undefined, ''); + if (provider === 'share_point') { + // Work and school accounts can browse "Shared with me" too. + connectorsService + .pickerToken(activeConnectionId, token) + .then((data) => setAllowsSharedContent(!!data?.allows_shared_content)) + .catch(() => undefined); + } + }, [activeConnectionId, provider, loadCloudFiles]); const handleScroll = useCallback(() => { const scrollContainer = scrollContainerRef.current; @@ -287,17 +289,20 @@ export const FilePicker: React.FC = ({ const { scrollTop, scrollHeight, clientHeight } = scrollContainer; const isNearBottom = scrollHeight - scrollTop - clientHeight < 50; - if (isNearBottom && hasMoreFiles && !isLoading && nextPageToken) { - const sessionToken = getSessionToken(provider); - if (sessionToken) { - loadCloudFiles( - sessionToken, - currentFolderId, - nextPageToken, - searchQuery, - activeTab === 'shared' && !currentFolderId, - ); - } + if ( + isNearBottom && + hasMoreFiles && + !isLoading && + nextPageToken && + activeConnectionId + ) { + loadCloudFiles( + activeConnectionId, + currentFolderId, + nextPageToken, + searchQuery, + activeTab === 'shared' && !currentFolderId, + ); } }, [ hasMoreFiles, @@ -305,7 +310,7 @@ export const FilePicker: React.FC = ({ nextPageToken, currentFolderId, searchQuery, - provider, + activeConnectionId, loadCloudFiles, activeTab, ]); @@ -325,10 +330,9 @@ export const FilePicker: React.FC = ({ }, []); const debouncedLoadFiles = useDebouncedCallback((query: string) => { - const sessionToken = getSessionToken(provider); - if (sessionToken) { + if (activeConnectionId) { loadCloudFiles( - sessionToken, + activeConnectionId, currentFolderId, undefined, query, @@ -353,9 +357,8 @@ export const FilePicker: React.FC = ({ setFolderPath((prev) => [...prev, { id: folderId, name: folderName }]); setSearchQuery(''); - const sessionToken = getSessionToken(provider); - if (sessionToken) { - loadCloudFiles(sessionToken, folderId, undefined, '', false); + if (activeConnectionId) { + loadCloudFiles(activeConnectionId, folderId, undefined, '', false); } }; @@ -369,10 +372,9 @@ export const FilePicker: React.FC = ({ setCurrentFolderId(newFolderId); setSearchQuery(''); - const sessionToken = getSessionToken(provider); - if (sessionToken) { + if (activeConnectionId) { loadCloudFiles( - sessionToken, + activeConnectionId, newFolderId, undefined, '', @@ -400,9 +402,8 @@ export const FilePicker: React.FC = ({ : getProviderConfig(provider).rootName, }, ]); - const sessionToken = getSessionToken(provider); - if (sessionToken) { - loadCloudFiles(sessionToken, null, undefined, '', tab === 'shared'); + if (activeConnectionId) { + loadCloudFiles(activeConnectionId, null, undefined, '', tab === 'shared'); } }; @@ -430,6 +431,13 @@ export const FilePicker: React.FC = ({ } }; + const switchAccount = (connectionId: string) => { + setChosenConnectionId(connectionId); + setSelectedFiles([]); + setSelectedFolders([]); + onSelectionChange([], []); + }; + return (
{authError && ( @@ -439,45 +447,57 @@ export const FilePicker: React.FC = ({ )} + {accounts.length > 1 && !controlledConnectionId && ( + + + + )} + { - setUserEmail( - data.user_email || - t('modals.uploadDoc.connectors.auth.connectedUser'), - ); - setIsConnected(true); setAuthError(''); - - if (data.session_token) { - setSessionToken(provider, data.session_token); - validateAndLoadFiles(); - } + dispatch(loadConnectors({ token })); + if (data.connection_id) setChosenConnectionId(data.connection_id); }} onError={(error) => { setAuthError(error); - setIsConnected(false); }} isConnected={isConnected} - userEmail={userEmail} + userEmail={ + activeAccount?.account_label || + t('modals.uploadDoc.connectors.auth.connectedUser') + } onDisconnect={() => { - const sessionToken = getSessionToken(provider); - if (sessionToken) { - userService - .disconnectConnector(provider, sessionToken, token) + if (activeConnectionId) { + connectorsService + .disconnect(activeConnectionId, token) .catch((err) => console.error( `Error disconnecting from ${getProviderConfig(provider).displayName}:`, err, ), - ); + ) + .finally(() => dispatch(loadConnectors({ token }))); } - - removeSessionToken(provider); - setIsConnected(false); + setChosenConnectionId(null); setAllowsSharedContent(false); setActiveTab('my_files'); setFiles([]); diff --git a/frontend/src/components/GoogleDrivePicker.tsx b/frontend/src/components/GoogleDrivePicker.tsx index 99f57abc..1c85c326 100644 --- a/frontend/src/components/GoogleDrivePicker.tsx +++ b/frontend/src/components/GoogleDrivePicker.tsx @@ -9,14 +9,15 @@ const useDrivePicker = (( drivePickerImport as unknown as { default?: typeof drivePickerImport } ).default ?? drivePickerImport) as typeof drivePickerImport; -import userService from '../api/services/userService'; -import ConnectorAuth from './ConnectorAuth'; +import { useDispatch, useSelector } from 'react-redux'; + +import connectorsService from '../api/services/connectorsService'; import { - getSessionToken, - setSessionToken, - removeSessionToken, - validateProviderSession, -} from '../utils/providerUtils'; + loadConnectors, + selectConnections, +} from '../connectors/connectorsSlice'; +import type { AppDispatch } from '../store'; +import ConnectorAuth from './ConnectorAuth'; import SkeletonLoader from './SkeletonLoader'; import { Button } from './ui/button'; import { SectionHeader } from './ui/section-header'; @@ -32,6 +33,10 @@ interface PickerFile { interface GoogleDrivePickerProps { token: string | null; + /** The Drive connection to pick from; defaults to the first connected one. */ + connectionId?: string | null; + /** Reports the account the picker uses, so the upload can name it. */ + onConnectionChange?: (connectionId: string | null) => void; onSelectionChange: (fileIds: string[], folderIds?: string[]) => void; /** Called with the first item's name when the selection goes from empty to one. */ onFirstPickName?: (name: string) => void; @@ -39,86 +44,77 @@ interface GoogleDrivePickerProps { const GoogleDrivePicker: React.FC = ({ token, + connectionId: controlledConnectionId, + onConnectionChange, onSelectionChange, onFirstPickName, }) => { const { t } = useTranslation(); + const dispatch = useDispatch(); + const connections = useSelector(selectConnections); + const [chosenConnectionId, setChosenConnectionId] = useState( + null, + ); + const accounts = connections.filter( + (c) => c.connector_key === 'google_drive' && c.status === 'connected', + ); + const activeConnectionId = + controlledConnectionId ?? + (chosenConnectionId && accounts.some((a) => a.id === chosenConnectionId) + ? chosenConnectionId + : (accounts[0]?.id ?? null)); + const activeAccount = accounts.find((a) => a.id === activeConnectionId); + const isConnected = !!activeConnectionId; const [selectedFiles, setSelectedFiles] = useState([]); const [selectedFolders, setSelectedFolders] = useState([]); const [isLoading, setIsLoading] = useState(false); - const [userEmail, setUserEmail] = useState(''); - const [isConnected, setIsConnected] = useState(false); const [authError, setAuthError] = useState(''); - const [accessToken, setAccessToken] = useState(null); const [isValidating, setIsValidating] = useState(false); const [openPicker] = useDrivePicker(); useEffect(() => { - const sessionToken = getSessionToken('google_drive'); - if (sessionToken) { - setIsValidating(true); - setIsConnected(true); // Optimistically set as connected for skeleton - validateSession(sessionToken); - } - }, [token]); + onConnectionChange?.(activeConnectionId); + }, [activeConnectionId]); - const validateSession = async (sessionToken: string) => { + // The Picker runs in the browser and needs an access token. It is fetched + // per use and kept in memory only; the refresh token never leaves the server. + const fetchAccessToken = async (): Promise => { + if (!activeConnectionId) return null; + setIsValidating(true); try { - const validateResponse = await validateProviderSession( + const data = await connectorsService.pickerToken( + activeConnectionId, token, - 'google_drive', ); - - if (!validateResponse.ok) { - setIsConnected(false); + if (!data?.success) { setAuthError( t('modals.uploadDoc.connectors.googleDrive.sessionExpired'), ); - setIsValidating(false); - return false; - } - - const validateData = await validateResponse.json(); - if (validateData.success) { - setUserEmail( - validateData.user_email || - t('modals.uploadDoc.connectors.auth.connectedUser'), - ); - setIsConnected(true); - setAuthError(''); - setAccessToken(validateData.access_token || null); - setIsValidating(false); - return true; - } else { - setIsConnected(false); - setAuthError( - validateData.error || - t('modals.uploadDoc.connectors.googleDrive.sessionExpiredGeneric'), - ); - setIsValidating(false); - return false; + dispatch(loadConnectors({ token })); + return null; } + setAuthError(''); + return data.access_token ?? null; } catch (error) { - console.error('Error validating session:', error); + console.error('Error fetching the picker token:', error); setAuthError(t('modals.uploadDoc.connectors.googleDrive.validateFailed')); - setIsConnected(false); + return null; + } finally { setIsValidating(false); - return false; } }; const handleOpenPicker = async () => { setIsLoading(true); - const sessionToken = getSessionToken('google_drive'); - - if (!sessionToken) { + if (!activeConnectionId) { setAuthError(t('modals.uploadDoc.connectors.googleDrive.noSession')); setIsLoading(false); return; } + const accessToken = await fetchAccessToken(); if (!accessToken) { setAuthError(t('modals.uploadDoc.connectors.googleDrive.noAccessToken')); setIsLoading(false); @@ -216,25 +212,17 @@ const GoogleDrivePicker: React.FC = ({ }; const handleDisconnect = async () => { - const sessionToken = getSessionToken('google_drive'); - if (sessionToken) { + if (activeConnectionId) { try { - await userService.disconnectConnector( - 'google_drive', - sessionToken, - token, - ); + await connectorsService.disconnect(activeConnectionId, token); } catch (err) { console.error('Error disconnecting from Google Drive:', err); } + dispatch(loadConnectors({ token })); } - - removeSessionToken('google_drive'); - setIsConnected(false); + setChosenConnectionId(null); setSelectedFiles([]); setSelectedFolders([]); - setAccessToken(null); - setUserEmail(''); setAuthError(''); onSelectionChange([], []); }; @@ -252,24 +240,18 @@ const GoogleDrivePicker: React.FC = ({ provider="google_drive" label={t('modals.uploadDoc.connectors.googleDrive.connect')} onSuccess={(data) => { - setUserEmail( - data.user_email || - t('modals.uploadDoc.connectors.auth.connectedUser'), - ); - setIsConnected(true); setAuthError(''); - - if (data.session_token) { - setSessionToken('google_drive', data.session_token); - validateSession(data.session_token); - } + dispatch(loadConnectors({ token })); + if (data.connection_id) setChosenConnectionId(data.connection_id); }} onError={(error) => { setAuthError(error); - setIsConnected(false); }} isConnected={isConnected} - userEmail={userEmail} + userEmail={ + activeAccount?.account_label || + t('modals.uploadDoc.connectors.auth.connectedUser') + } onDisconnect={handleDisconnect} errorMessage={authError} /> diff --git a/frontend/src/connectors/ConnectWizard.test.tsx b/frontend/src/connectors/ConnectWizard.test.tsx new file mode 100644 index 00000000..cf04e8e6 --- /dev/null +++ b/frontend/src/connectors/ConnectWizard.test.tsx @@ -0,0 +1,303 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: Record) => + opts && 'count' in opts ? `${key}:${opts.count}` : key, + }), +})); + +const service = vi.hoisted(() => ({ + createConnection: vi.fn(), + setup: vi.fn(), + reconnect: vi.fn(), + getConnection: vi.fn(), + getCatalog: vi.fn(), + listConnections: vi.fn(), + setToolPermissions: vi.fn(), + pickerToken: vi.fn(), +})); +vi.mock('../api/services/connectorsService', () => ({ default: service })); + +// The OAuth popup is covered by ConnectorAuth's own tests; here it only has +// to report a finished sign-in. +vi.mock('../components/ConnectorAuth', () => ({ + default: ({ + onSuccess, + label, + }: { + onSuccess: (data: { connection_id: string; user_email: string }) => void; + label: string; + }) => ( + + ), +})); +vi.mock('../components/FilePicker', () => ({ + FilePicker: ({ + onSelectionChange, + onFirstPickName, + }: { + onSelectionChange: (files: string[], folders?: string[]) => void; + onFirstPickName?: (name: string) => void; + }) => ( + + ), +})); + +import connectorsReducer from './connectorsSlice'; +import ConnectWizard from './ConnectWizard'; +import type { ConnectorDefinition } from './types'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const base: ConnectorDefinition = { + key: 'telegram', + name: 'Telegram', + description: 'Send messages.', + icon: 'tool_telegram', + category: 'messaging', + auth_kind: 'api_key', + capabilities: ['write'], + credential_fields: [ + { key: 'token', label: 'Bot token', secret: true, required: true }, + ], + setup_fields: [], + sync_ingestor: null, + default_sync_frequency: 'weekly', + tool_templates: ['telegram'], + setup: { tools: 'auto', sync: 'off' }, + mcp_url: null, + publisher: 'built_in', + docs_url: null, + oauth_scopes: [], + available: true, + disabled: false, + needs_setup: false, + missing_settings: [], + connected_count: 0, + connection_count: 0, + status: null, + state: 'available', + credential_policy: 'choose', +}; + +const drive: ConnectorDefinition = { + ...base, + key: 'google_drive', + name: 'Google Drive', + icon: 'drive', + category: 'files', + auth_kind: 'oauth', + capabilities: ['sync'], + credential_fields: [], + sync_ingestor: 'google_drive', + tool_templates: [], + setup: { tools: 'off', sync: 'ask' }, +}; + +const TELEGRAM_TOOL = { + id: 'tool-1', + name: 'telegram', + display_name: 'Telegram', + status: true, + credential_mode: 'owner', + actions: [ + { + name: 'telegram_send_message', + description: 'Send', + access: 'write', + permission: 'ask', + }, + ], +}; + +describe('ConnectWizard', () => { + let root: Root; + let container: HTMLDivElement; + + beforeEach(() => { + Object.values(service).forEach((fn) => fn.mockReset()); + service.getCatalog.mockResolvedValue({ success: true, connectors: [] }); + service.listConnections.mockResolvedValue({ + success: true, + connections: [], + }); + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + }); + + const render = async (connector: ConnectorDefinition, onClose = vi.fn()) => { + const store = configureStore({ + reducer: { + connectors: connectorsReducer, + preference: (state = { token: null, selectedDocs: [] }) => state, + conversation: (state = {}) => state, + }, + }); + await act(async () => { + root.render( + + + + + } + /> + NEW_CHAT
} /> + + + , + ); + }); + }; + + const click = async (text: string) => { + const button = Array.from( + document.body.querySelectorAll('button'), + ).find((b) => b.textContent?.trim() === text); + expect(button, `button ${text}`).toBeDefined(); + await act(async () => button!.click()); + }; + + const typeInto = async (input: HTMLInputElement, value: string) => { + const setter = Object.getOwnPropertyDescriptor( + HTMLInputElement.prototype, + 'value', + )!.set!; + await act(async () => { + setter.call(input, value); + input.dispatchEvent(new Event('input', { bubbles: true })); + }); + }; + + it('connects an API-key service and creates its tools with no further questions', async () => { + service.createConnection.mockResolvedValue({ + success: true, + connection: { id: 'conn-1' }, + }); + service.setup.mockResolvedValue({ + success: true, + tools: [TELEGRAM_TOOL], + sources: [], + }); + await render(base); + const input = document.body.querySelector( + 'input[type="password"]', + )!; + await typeInto(input, '123:abc'); + await click('settings.connectors.status.connect'); + expect(service.createConnection).toHaveBeenCalledWith( + { connector_key: 'telegram', credentials: { token: '123:abc' } }, + null, + ); + expect(service.setup).toHaveBeenCalledWith( + 'conn-1', + { create_tools: true }, + null, + ); + // Straight to the summary: no sync step for a tool-only service. + expect(document.body.textContent).toContain( + 'settings.connectors.wizard.doneTitle', + ); + expect(document.body.textContent).toContain( + 'settings.connectors.wizard.toolsHeading:1', + ); + }); + + it('shows the default-key refusal in the form', async () => { + service.createConnection.mockResolvedValue({ + success: false, + code: 'encryption_key_default', + }); + await render(base); + await typeInto( + document.body.querySelector('input[type="password"]')!, + 'x', + ); + await click('settings.connectors.status.connect'); + expect(document.body.textContent).toContain( + 'settings.connectors.error.defaultKey', + ); + }); + + it('lets a content service skip choosing what to sync', async () => { + await render(drive); + await click('settings.connectors.wizard.signIn'); + expect(document.body.textContent).toContain( + 'settings.connectors.wizard.chooseWhatToSync', + ); + await click('settings.connectors.wizard.skip'); + expect(document.body.textContent).toContain( + 'settings.connectors.wizard.doneTitle', + ); + expect(service.setup).not.toHaveBeenCalled(); + }); + + it('syncs the picked folder named after it', async () => { + service.setup.mockResolvedValue({ + success: true, + tools: [], + sources: [{ id: 'src-1', name: 'Handbook' }], + }); + await render(drive); + await click('settings.connectors.wizard.signIn'); + await click('pick-folder'); + await click('modals.uploadDoc.train'); + const [id, body, , key] = service.setup.mock.calls[0]; + expect(id).toBe('conn-drive'); + expect(body.sync).toEqual({ + items: { file_ids: [], folder_ids: ['folder-1'] }, + frequency: 'weekly', + name: 'Handbook', + }); + expect(typeof key).toBe('string'); + expect(document.body.textContent).toContain( + 'settings.connectors.wizard.doneSummary', + ); + }); + + it('opens a new chat from Try it in chat', async () => { + service.createConnection.mockResolvedValue({ + success: true, + connection: { id: 'conn-1' }, + }); + service.setup.mockResolvedValue({ success: true, tools: [], sources: [] }); + const onClose = vi.fn(); + await render(base, onClose); + await typeInto( + document.body.querySelector('input[type="password"]')!, + 't', + ); + await click('settings.connectors.status.connect'); + await click('settings.connectors.wizard.tryInChat'); + expect(onClose).toHaveBeenCalled(); + expect(document.body.textContent).toContain('NEW_CHAT'); + }); +}); diff --git a/frontend/src/connectors/ConnectWizard.tsx b/frontend/src/connectors/ConnectWizard.tsx new file mode 100644 index 00000000..3cc1c23c --- /dev/null +++ b/frontend/src/connectors/ConnectWizard.tsx @@ -0,0 +1,484 @@ +import { CircleAlert } from 'lucide-react'; +import { nanoid } from '@reduxjs/toolkit'; +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; + +import { envVar } from '@/env'; +import connectorsService from '../api/services/connectorsService'; +import ConnectorAuth from '../components/ConnectorAuth'; +import { FilePicker } from '../components/FilePicker'; +import GoogleDrivePicker from '../components/GoogleDrivePicker'; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from '../components/ui/accordion'; +import { Alert, AlertDescription } from '../components/ui/alert'; +import { FormField } from '../components/ui/form-field'; +import { Input } from '../components/ui/input'; +import { Modal, ModalActions } from '../components/ui/modal'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../components/ui/select'; +import { + setConversation, + updateConversationId, +} from '../conversation/conversationSlice'; +import { + selectToken, + setSelectedAgent, + setSelectedDocs, +} from '../preferences/preferenceSlice'; +import type { AppDispatch } from '../store'; +import ConnectorIcon from './ConnectorIcon'; +import CredentialForm, { credentialsComplete } from './CredentialForm'; +import { loadConnectors, selectConnections } from './connectorsSlice'; +import { connectorDescription, connectorName } from './i18n'; +import ToolPermissions from './ToolPermissions'; +import type { ConnectionTool, ConnectorDefinition } from './types'; + +export type WizardMode = 'connect' | 'reconnect' | 'sync' | 'done'; + +const FREQUENCIES = ['never', 'daily', 'weekly', 'monthly'] as const; +const PICKER_CONNECTORS = new Set([ + 'google_drive', + 'share_point', + 'confluence', +]); + +type CreatedSource = { id: string; name: string }; + +/** + * The one connect flow every entry point opens: sign in, choose what to + * sync (content connectors only, skippable), then a summary with Try it in + * chat. Tool connectors create their tools on sign-in, writes needing + * approval, so they go from the credentials straight to the summary. + */ +export default function ConnectWizard({ + connector, + mode = 'connect', + connectionId: initialConnectionId = null, + onClose, + onFinished, +}: { + connector: ConnectorDefinition; + mode?: WizardMode; + connectionId?: string | null; + onClose: () => void; + onFinished?: () => void; +}) { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const navigate = useNavigate(); + const token = useSelector(selectToken); + const connections = useSelector(selectConnections); + const name = connectorName(t, connector); + + const [step, setStep] = useState<'signin' | 'setup' | 'done'>( + mode === 'sync' ? 'setup' : mode === 'done' ? 'done' : 'signin', + ); + const [connectionId, setConnectionId] = useState( + initialConnectionId, + ); + const [credentials, setCredentials] = useState>({}); + const [setupValues, setSetupValues] = useState>({}); + const [pending, setPending] = useState(false); + const [error, setError] = useState(''); + const [tools, setTools] = useState([]); + const [sources, setSources] = useState([]); + const [selectedFiles, setSelectedFiles] = useState([]); + const [selectedFolders, setSelectedFolders] = useState([]); + const [sourceName, setSourceName] = useState(''); + const [nameTouched, setNameTouched] = useState(false); + const [frequency, setFrequency] = useState( + connector.default_sync_frequency || 'weekly', + ); + // One key per wizard: a double click on Add source queues one ingest. + const [idempotencyKey] = useState(() => nanoid()); + + const connection = connections.find((c) => c.id === connectionId); + const canSync = connector.setup.sync !== 'off' && !!connector.sync_ingestor; + + // A finished MCP save or a reconnect lands here with only the connection id. + useEffect(() => { + if (mode !== 'done' || !connectionId) return; + connectorsService.getConnection(connectionId, token).then((data) => { + if (data?.success) setTools(data.connection.tools ?? []); + }); + }, [mode, connectionId, token]); + + const refresh = () => dispatch(loadConnectors({ token })); + + const afterSignIn = async (id: string) => { + setConnectionId(id); + refresh(); + if (mode === 'reconnect') { + setStep('done'); + return; + } + if (connector.setup.tools === 'auto' && connector.tool_templates.length) { + const setup = await connectorsService.setup( + id, + { create_tools: true }, + token, + ); + if (setup?.success) setTools(setup.tools ?? []); + } + setStep(canSync ? 'setup' : 'done'); + }; + + const submitCredentials = async () => { + setPending(true); + setError(''); + try { + const data = + mode === 'reconnect' && connectionId + ? await connectorsService.reconnect( + connectionId, + { credentials }, + token, + ) + : await connectorsService.createConnection( + { connector_key: connector.key, credentials }, + token, + ); + if (!data?.success) { + setError( + data?.code === 'encryption_key_default' + ? t('settings.connectors.error.defaultKey') + : t('settings.connectors.wizard.connectFailed'), + ); + return; + } + await afterSignIn(data.connection.id); + } catch { + setError(t('settings.connectors.wizard.connectFailed')); + } finally { + setPending(false); + } + }; + + const prefillName = (picked: string) => { + if (!nameTouched && picked) setSourceName((current) => current || picked); + }; + + const syncItems = (): Record => + PICKER_CONNECTORS.has(connector.key) + ? { file_ids: selectedFiles, folder_ids: selectedFolders } + : setupValues; + + const canAddSource = PICKER_CONNECTORS.has(connector.key) + ? selectedFiles.length + selectedFolders.length > 0 + : credentialsComplete(connector.setup_fields, setupValues); + + const addSource = async () => { + if (!connectionId) return; + setPending(true); + setError(''); + try { + const data = await connectorsService.setup( + connectionId, + { + create_tools: false, + sync: { + items: syncItems(), + frequency, + name: sourceName.trim() || undefined, + }, + }, + token, + idempotencyKey, + ); + if (!data?.success) { + setError(data?.error || t('settings.connectors.wizard.syncFailed')); + return; + } + setSources(data.sources ?? []); + refresh(); + setStep('done'); + } catch { + setError(t('settings.connectors.wizard.syncFailed')); + } finally { + setPending(false); + } + }; + + const tryInChat = () => { + dispatch(setConversation([])); + dispatch(updateConversationId({ query: { conversationId: null } })); + dispatch(setSelectedAgent(null)); + if (sources.length > 0) { + dispatch( + setSelectedDocs( + sources.map((source) => ({ + id: source.id, + name: source.name, + date: new Date().toISOString(), + model: '', + type: 'connector:file', + connectionId, + })), + ), + ); + } + onFinished?.(); + onClose(); + navigate('/c/new'); + }; + + const finish = () => { + onFinished?.(); + onClose(); + }; + + const toolCount = tools.length; + const summary = useMemo(() => { + const account = connection?.account_label ?? ''; + const accountLine = + connection?.auth_kind === 'api_key' + ? t('settings.connectors.wizard.connectedWithKey', { + hint: account, + interpolation: { escapeValue: false }, + }) + : t('settings.connectors.wizard.doneSummaryNone', { + account, + interpolation: { escapeValue: false }, + }); + const sourcesText = t('settings.connectors.wizard.sourcesCount', { + count: sources.length, + }); + const toolsText = t('settings.connectors.wizard.toolsCount', { + count: toolCount, + }); + const countsLine = + sources.length && toolCount + ? t('settings.connectors.wizard.doneCounts', { + sources: sourcesText, + tools: toolsText, + }) + : sources.length + ? t('settings.connectors.wizard.doneSources', { + sources: sourcesText, + }) + : toolCount + ? t('settings.connectors.wizard.doneTools', { tools: toolsText }) + : ''; + return [accountLine, countsLine].filter(Boolean).join(' '); + }, [connection, sources, toolCount, t]); + + const renderSignIn = () => ( +
+
+ + + +

+ {connectorDescription(t, connector)} +

+
+ {error && ( + + + {error} + + )} + {connector.auth_kind === 'oauth' ? ( + afterSignIn(data.connection_id)} + onError={setError} + /> + ) : ( + + )} +
+ ); + + const renderSetup = () => ( +
+ {error && ( + + + {error} + + )} + {connector.key === 'google_drive' && envVar('VITE_GOOGLE_CLIENT_ID') ? ( + { + setSelectedFiles(fileIds); + setSelectedFolders(folderIds); + }} + /> + ) : PICKER_CONNECTORS.has(connector.key) ? ( + { + setSelectedFiles(fileIds); + setSelectedFolders(folderIds); + }} + /> + ) : ( + { + setSetupValues(values); + const first = Object.values(values).find(Boolean); + if (first) prefillName(first); + }} + /> + )} +
+ { + setNameTouched(true); + setSourceName(e.target.value); + }} + /> + + + +
+
+ ); + + const renderDone = () => ( +
+

{summary}

+ {toolCount > 0 && connectionId && ( +
+ + + + {t('settings.connectors.wizard.toolsHeading', { + count: toolCount, + })} + + +
+ {tools.map((tool) => ( + + ))} +
+
+
+
+
+ )} +
+ ); + + const title = + step === 'signin' + ? mode === 'reconnect' + ? t('settings.connectors.wizard.reconnectTitle', { + name, + interpolation: { escapeValue: false }, + }) + : t('settings.connectors.wizard.connectTitle', { + name, + interpolation: { escapeValue: false }, + }) + : step === 'setup' + ? t('settings.connectors.wizard.chooseWhatToSync') + : t('settings.connectors.wizard.doneTitle', { + name, + interpolation: { escapeValue: false }, + }); + + const footer = + step === 'signin' ? ( + connector.auth_kind === 'oauth' ? undefined : ( + + ) + ) : step === 'setup' ? ( + (mode === 'sync' ? onClose() : setStep('done'))} + submitLabel={t('modals.uploadDoc.train')} + onSubmit={addSource} + pending={pending} + disabled={!canAddSource} + /> + ) : ( + + ); + + return ( + !open && (step === 'done' ? finish() : onClose())} + title={title} + size={step === 'setup' ? 'xl' : 'lg'} + mobileVariant="sheet" + footer={footer} + > + {step === 'signin' && renderSignIn()} + {step === 'setup' && renderSetup()} + {step === 'done' && renderDone()} + + ); +} diff --git a/frontend/src/connectors/ConnectionDrawer.tsx b/frontend/src/connectors/ConnectionDrawer.tsx index e37453a6..e6200cff 100644 --- a/frontend/src/connectors/ConnectionDrawer.tsx +++ b/frontend/src/connectors/ConnectionDrawer.tsx @@ -3,6 +3,8 @@ import { ExternalLink, Plus, RefreshCw, + RotateCw, + Trash2, Unplug, } from 'lucide-react'; import { useCallback, useEffect, useState } from 'react'; @@ -14,26 +16,31 @@ import { Alert, AlertDescription, AlertTitle } from '../components/ui/alert'; import { Badge } from '../components/ui/badge'; import { Button } from '../components/ui/button'; import { Card } from '../components/ui/card'; -import { ActionMenu } from '../components/ui/dropdown-menu'; +import { ActionMenu, type MenuOption } from '../components/ui/dropdown-menu'; import { EmptyState } from '../components/ui/empty-state'; +import { FormField } from '../components/ui/form-field'; import { ListRow, ListRows } from '../components/ui/list-row'; import { LoadingState } from '../components/ui/loading-state'; +import { Modal, ModalActions } from '../components/ui/modal'; import { SectionHeader } from '../components/ui/section-header'; import { Sheet, SheetContent, SheetTitle } from '../components/ui/sheet'; +import { ToggleGroup, ToggleGroupItem } from '../components/ui/toggle-group'; import ConfirmationModal from '../modals/ConfirmationModal'; +import { showActionToast } from '../notifications/actionToastSlice'; import { selectToken } from '../preferences/preferenceSlice'; import type { AppDispatch } from '../store'; import { formatDateTime } from '../utils/dateTimeUtils'; import { CapabilityBadges } from './ConnectorCard'; import ConnectorIcon from './ConnectorIcon'; -import { selectConnections, loadConnectors } from './connectorsSlice'; +import { loadConnectors, selectConnections } from './connectorsSlice'; import { connectorDescription, connectorName } from './i18n'; +import ToolPermissions from './ToolPermissions'; import type { ConnectionDetail, ConnectionStatus, - ConnectionTool, ConnectorDefinition, } from './types'; +import type { LaunchOptions } from './useConnectorLauncher'; const STATUS_VARIANT: Record< ConnectionStatus, @@ -46,82 +53,176 @@ const STATUS_VARIANT: Record< pending: 'neutral', }; -const PERMISSION_VARIANT = { - always: 'success', - ask: 'warning', - off: 'neutral', -} as const; - -function ToolActions({ tool }: { tool: ConnectionTool }) { +/** "Connected as …", or the key hint for pasted credentials. */ +function useAccountTitle() { const { t } = useTranslation(); - const groups = (['read', 'write'] as const) - .map((access) => ({ - access, - actions: tool.actions.filter((action) => action.access === access), - })) - .filter((group) => group.actions.length > 0); + return (detail: ConnectionDetail) => + detail.auth_kind === 'api_key' + ? t('settings.connectors.detail.keyEnding', { + hint: detail.account_label, + interpolation: { escapeValue: false }, + }) + : t('settings.connectors.detail.connectedAs', { + account: detail.account_label, + interpolation: { escapeValue: false }, + }); +} + +function RemoveConnectionModal({ + detail, + name, + onClose, + onRemoved, +}: { + detail: ConnectionDetail; + name: string; + onClose: () => void; + onRemoved: () => void; +}) { + const { t } = useTranslation(); + const token = useSelector(selectToken); + const [sources, setSources] = useState<'keep' | 'delete'>('keep'); + const [tools, setTools] = useState<'keep' | 'delete'>('delete'); + const [pending, setPending] = useState(false); + const [failed, setFailed] = useState(false); + + const remove = () => { + setPending(true); + setFailed(false); + connectorsService + .remove(detail.id, { sources, tools }, token) + .then((data) => { + if (!data?.success) throw new Error('remove failed'); + onRemoved(); + }) + .catch(() => setFailed(true)) + .finally(() => setPending(false)); + }; + return ( - -
- - {tool.display_name} - - - {tool.status - ? t('settings.connectors.detail.toolOn') - : t('settings.connectors.detail.toolOff')} - + !open && onClose()} + title={t('settings.connectors.remove.title', { + name, + interpolation: { escapeValue: false }, + })} + description={t('settings.connectors.remove.description')} + footer={ + + } + > +
+ {failed && ( + + + + {t('settings.connectors.remove.failed')} + + + )} + {detail.sources.length > 0 && ( + + + value && setSources(value as 'keep' | 'delete') + } + > + + {t('settings.connectors.remove.keepSources')} + + + {t('settings.connectors.remove.deleteSources')} + + + + )} + {detail.tools.length > 0 && ( + + + value && setTools(value as 'keep' | 'delete') + } + > + + {t('settings.connectors.remove.deleteTools')} + + + {t('settings.connectors.remove.keepTools')} + + + + )}
- {groups.map((group) => ( -
- -
    - {group.actions.map((action) => ( -
  • - - {action.name} - - - {t(`settings.connectors.permission.${action.permission}`)} - -
  • - ))} -
-
- ))} - +
); } function AccountSection({ + connector, detail, onReconnect, onDisconnect, + onRemove, + onSyncMore, + onRefreshTools, }: { + connector: ConnectorDefinition; detail: ConnectionDetail; - onReconnect: () => void; + onReconnect: (detail: ConnectionDetail) => void; onDisconnect: (detail: ConnectionDetail) => void; + onRemove: (detail: ConnectionDetail) => void; + onSyncMore: (detail: ConnectionDetail) => void; + onRefreshTools: (detail: ConnectionDetail) => Promise; }) { const { t } = useTranslation(); + const accountTitle = useAccountTitle(); + const [refreshing, setRefreshing] = useState(false); + const canSync = connector.setup.sync !== 'off' && !!connector.sync_ingestor; + const isMcp = detail.tools.some((tool) => tool.name === 'mcp_tool'); + const menu: MenuOption[] = []; + if (detail.status !== 'disconnected') { + menu.push({ + icon: Unplug, + label: t('settings.connectors.detail.disconnect'), + onClick: () => onDisconnect(detail), + variant: 'destructive', + }); + } + menu.push({ + icon: Trash2, + label: t('settings.connectors.detail.remove'), + onClick: () => onRemove(detail), + variant: 'destructive', + }); + return (
{detail.last_error} @@ -137,66 +238,77 @@ function AccountSection({ type="button" size="xs" variant="outline" - onClick={onReconnect} + onClick={() => onReconnect(detail)} > {t('settings.connectors.status.reconnect')} )} - {detail.status !== 'disconnected' && ( - onDisconnect(detail), - variant: 'destructive', - }, - ]} - /> - )} +
} /> - {detail.sources.length > 0 && ( + {canSync && (
onSyncMore(detail)} + > + + {t('settings.connectors.detail.syncMore')} + + ) : undefined + } /> - - - {detail.sources.map((source) => ( - - {t('settings.connectors.detail.paused')} - - ) : undefined - } - /> - ))} - - + {detail.sources.length === 0 ? ( +

+ {t('settings.connectors.detail.noSources')} +

+ ) : ( + + + {detail.sources.map((source) => ( + + {t('settings.connectors.detail.paused')} + + ) : undefined + } + /> + ))} + + + )}
)} {detail.tools.length > 0 && ( @@ -205,9 +317,39 @@ function AccountSection({ as="h3" size="xs" title={t('settings.connectors.detail.tools')} + actions={ + isMcp && detail.status === 'connected' ? ( + + ) : undefined + } /> {detail.tools.map((tool) => ( - +
+
+ + {tool.display_name} + + + {tool.status + ? t('settings.connectors.detail.toolOn') + : t('settings.connectors.detail.toolOff')} + +
+ +
))} )} @@ -217,7 +359,8 @@ function AccountSection({ /** * Everything about one connector: its accounts, the sources each syncs and - * the tools each provides. Opens from a Connectors page card. + * the tools each provides, with their permissions. Opens from a Connectors + * page card. */ export default function ConnectionDrawer({ connector, @@ -226,7 +369,7 @@ export default function ConnectionDrawer({ }: { connector: ConnectorDefinition | null; onClose: () => void; - onConnect: (connector: ConnectorDefinition) => void; + onConnect: (connector: ConnectorDefinition, options?: LaunchOptions) => void; }) { const { t } = useTranslation(); const dispatch = useDispatch(); @@ -239,15 +382,18 @@ export default function ConnectionDrawer({ const [toDisconnect, setToDisconnect] = useState( null, ); + const [toRemove, setToRemove] = useState(null); const accountIds = connections .filter((connection) => connection.connector_key === connector?.key) - .map((connection) => connection.id) + .map((connection) => `${connection.id}:${connection.status}`) .join(','); useEffect(() => { if (!connector) return; - const ids = accountIds ? accountIds.split(',') : []; + const ids = accountIds + ? accountIds.split(',').map((entry) => entry.split(':')[0]) + : []; let cancelled = false; setLoading(true); setFailed(false); @@ -280,6 +426,45 @@ export default function ConnectionDrawer({ }); }; + const reconnect = (detail: ConnectionDetail) => { + if (!connector) return; + const mcpTool = detail.tools.find((tool) => tool.name === 'mcp_tool'); + onConnect(connector, { + mode: 'reconnect', + connectionId: detail.id, + mcpServer: + mcpTool && detail.server_url + ? { + id: mcpTool.id, + displayName: mcpTool.display_name, + server_url: detail.server_url, + auth_type: detail.auth_kind === 'mcp_oauth' ? 'oauth' : 'none', + } + : undefined, + }); + }; + + const refreshTools = async (detail: ConnectionDetail) => { + const data = await connectorsService.refreshTools(detail.id, token); + dispatch( + showActionToast( + data?.success + ? { + variant: 'success', + message: t('settings.connectors.detail.refreshed', { + added: data.added?.length ?? 0, + removed: data.removed?.length ?? 0, + }), + } + : { + variant: 'destructive', + message: t('settings.connectors.detail.refreshFailed'), + }, + ), + ); + refresh(); + }; + if (!connector) return null; const name = connectorName(t, connector); @@ -308,6 +493,15 @@ export default function ConnectionDrawer({

+ {connector.publisher === 'custom' && ( + + + + {t('settings.connectors.unverified')} + + + )} + {connector.needs_setup && ( @@ -368,7 +562,7 @@ export default function ConnectionDrawer({ ) : undefined } /> - {loading ? ( + {loading && details.length === 0 ? ( ) : failed ? ( ( onConnect(connector)} + onReconnect={reconnect} onDisconnect={setToDisconnect} + onRemove={setToRemove} + onSyncMore={(d) => + onConnect(connector, { + mode: 'sync', + connectionId: d.id, + }) + } + onRefreshTools={refreshTools} /> ))}
@@ -433,6 +636,17 @@ export default function ConnectionDrawer({ submitLabel={t('settings.connectors.detail.disconnect')} variant="destructive" /> + {toRemove && ( + setToRemove(null)} + onRemoved={() => { + setToRemove(null); + refresh(); + }} + /> + )} ); } diff --git a/frontend/src/connectors/CredentialForm.tsx b/frontend/src/connectors/CredentialForm.tsx new file mode 100644 index 00000000..94353c41 --- /dev/null +++ b/frontend/src/connectors/CredentialForm.tsx @@ -0,0 +1,56 @@ +import { useTranslation } from 'react-i18next'; + +import { Input } from '../components/ui/input'; +import type { CredentialField } from './types'; + +/** + * The short form an API-key connector asks for, generated from its catalog + * `credential_fields` (or `setup_fields`). Secrets are masked. Labels come + * from `settings.connectors.fields._`, then + * `settings.connectors.fields.`, then the catalog label. + */ +export default function CredentialForm({ + connectorKey, + fields, + values, + onChange, + idPrefix, + labelSurface = 'card', +}: { + /** Picks the connector's own label for a shared key (Telegram's "Bot token"). */ + connectorKey: string; + fields: CredentialField[]; + values: Record; + onChange: (values: Record) => void; + idPrefix: string; + labelSurface?: 'card' | 'background' | 'muted'; +}) { + const { t } = useTranslation(); + return ( +
+ {fields.map((field) => ( + onChange({ ...values, [field.key]: e.target.value })} + /> + ))} +
+ ); +} + +/** Whether every required field has a value. */ +export const credentialsComplete = ( + fields: CredentialField[], + values: Record, +) => fields.every((field) => !field.required || !!values[field.key]?.trim()); diff --git a/frontend/src/connectors/ToolPermissions.tsx b/frontend/src/connectors/ToolPermissions.tsx new file mode 100644 index 00000000..c7b05cff --- /dev/null +++ b/frontend/src/connectors/ToolPermissions.tsx @@ -0,0 +1,118 @@ +import { useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; + +import connectorsService from '../api/services/connectorsService'; +import { Card } from '../components/ui/card'; +import { SectionHeader } from '../components/ui/section-header'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '../components/ui/select'; +import { selectToken } from '../preferences/preferenceSlice'; +import type { + ActionPermission, + ConnectionTool, + ConnectionToolAction, +} from './types'; + +const PERMISSIONS: ActionPermission[] = ['always', 'ask', 'off']; + +/** + * One tool's actions, grouped Read and Write, each with Always allow / + * Needs approval / Off. Changes save immediately; a failed save puts the + * previous choice back. + */ +export default function ToolPermissions({ + connectionId, + tool, + onChange, + readOnly = false, +}: { + connectionId: string; + tool: ConnectionTool; + onChange?: (tool: ConnectionTool) => void; + readOnly?: boolean; +}) { + const { t } = useTranslation(); + const token = useSelector(selectToken); + const [actions, setActions] = useState(tool.actions); + + const setPermission = (name: string, permission: ActionPermission) => { + const previous = actions; + const next = actions.map((action) => + action.name === name ? { ...action, permission } : action, + ); + setActions(next); + connectorsService + .setToolPermissions(connectionId, tool.id, { [name]: permission }, token) + .then((data) => { + if (!data?.success) throw new Error('save failed'); + onChange?.(data.tool); + }) + .catch(() => setActions(previous)); + }; + + const groups = (['read', 'write'] as const) + .map((access) => ({ + access, + actions: actions.filter((action) => action.access === access), + })) + .filter((group) => group.actions.length > 0); + + return ( + + {groups.map((group) => ( +
+ +
    + {group.actions.map((action) => ( +
  • + + {action.name} + + +
  • + ))} +
+
+ ))} +
+ ); +} diff --git a/frontend/src/connectors/connectorsSlice.ts b/frontend/src/connectors/connectorsSlice.ts index cc26674b..e6771ab2 100644 --- a/frontend/src/connectors/connectorsSlice.ts +++ b/frontend/src/connectors/connectorsSlice.ts @@ -1,9 +1,15 @@ -import { createAsyncThunk, createSlice } from '@reduxjs/toolkit'; +import { + createAsyncThunk, + createSlice, + type PayloadAction, +} from '@reduxjs/toolkit'; import connectorsService from '../api/services/connectorsService'; import type { Connection, ConnectorDefinition } from './types'; export type ConnectorsState = { + /** False when the backend predates connectors (`/api/config`). */ + enabled: boolean; catalog: ConnectorDefinition[]; connections: Connection[]; loading: boolean; @@ -12,6 +18,7 @@ export type ConnectorsState = { }; const initialState: ConnectorsState = { + enabled: true, catalog: [], connections: [], loading: false, @@ -44,7 +51,11 @@ export const loadConnectors = createAsyncThunk< const connectorsSlice = createSlice({ name: 'connectors', initialState, - reducers: {}, + reducers: { + setConnectorsEnabled(state, action: PayloadAction) { + state.enabled = action.payload; + }, + }, extraReducers: (builder) => { builder .addCase(loadConnectors.pending, (state) => { @@ -64,8 +75,13 @@ const connectorsSlice = createSlice({ }, }); +export const { setConnectorsEnabled } = connectorsSlice.actions; + type RootLike = { connectors: ConnectorsState }; +export const selectConnectorsEnabled = (state: RootLike) => + state.connectors?.enabled !== false; + export const selectConnectorCatalog = (state: RootLike) => state.connectors.catalog; export const selectConnections = (state: RootLike) => diff --git a/frontend/src/connectors/useConnectorLauncher.tsx b/frontend/src/connectors/useConnectorLauncher.tsx index df8d81d1..bc1b033d 100644 --- a/frontend/src/connectors/useConnectorLauncher.tsx +++ b/frontend/src/connectors/useConnectorLauncher.tsx @@ -2,28 +2,46 @@ import { useCallback, useState, type ReactNode } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import { useNavigate } from 'react-router-dom'; +import connectorsService from '../api/services/connectorsService'; import userService from '../api/services/userService'; -import ConfigToolModal from '../modals/ConfigToolModal'; import MCPServerModal from '../modals/MCPServerModal'; import type { AvailableToolType } from '../modals/types'; import type { ActiveState } from '../models/misc'; import { selectToken } from '../preferences/preferenceSlice'; import type { AppDispatch } from '../store'; -import Upload from '../upload/Upload'; -import type { IngestorType } from '../upload/types/ingestor'; +import ConnectWizard, { type WizardMode } from './ConnectWizard'; import { loadConnectors } from './connectorsSlice'; import type { ConnectorDefinition } from './types'; -type Launch = - | { kind: 'source'; ingestor: IngestorType } - | { kind: 'tool'; tool: AvailableToolType } - | { kind: 'mcp'; server?: Record } +export type LaunchOptions = { + /** `connect` a new account, `reconnect` one, or `sync` more from one. */ + mode?: Exclude; + connectionId?: string; + /** An existing MCP tool to reconnect through the MCP server form. */ + mcpServer?: Record; +}; + +type Active = + | { + kind: 'wizard'; + connector: ConnectorDefinition; + mode: WizardMode; + connectionId?: string; + } + | { + kind: 'mcp'; + connector: ConnectorDefinition; + server?: Record; + } | null; +const isMcp = (connector: ConnectorDefinition) => + connector.auth_kind === 'mcp' || connector.auth_kind === 'mcp_oauth'; + /** - * One way to start connecting any catalog entry, used by every entry point - * (the Connectors page, Add Source, Add Tool). Returns `launch` and the - * modals it drives; render `modals` once where the hook is used. + * The one way to start connecting a catalog entry, used by every entry point + * (the Connectors page and drawer, Add Source, Add Tool, the chat's Connect + * card). Returns `launch` and the modals it drives; render `modals` once. */ export default function useConnectorLauncher({ onConnected, @@ -31,52 +49,43 @@ export default function useConnectorLauncher({ const dispatch = useDispatch(); const navigate = useNavigate(); const token = useSelector(selectToken); - const [active, setActive] = useState(null); + const [active, setActive] = useState(null); - const finish = useCallback(() => { - setActive(null); + const refresh = useCallback(() => { dispatch(loadConnectors({ token })); onConnected?.(); }, [dispatch, token, onConnected]); const launch = useCallback( - async (connector: ConnectorDefinition) => { - if (connector.sync_ingestor) { - setActive({ - kind: 'source', - ingestor: connector.sync_ingestor as IngestorType, - }); - return; - } - if ( - connector.auth_kind === 'mcp' || - connector.auth_kind === 'mcp_oauth' - ) { + async (connector: ConnectorDefinition, options: LaunchOptions = {}) => { + if (isMcp(connector)) { setActive({ kind: 'mcp', - server: connector.mcp_url - ? { - displayName: connector.name, - server_url: connector.mcp_url, - auth_type: - connector.auth_kind === 'mcp_oauth' ? 'oauth' : 'none', - preset: true, - } - : undefined, + connector, + server: + options.mcpServer ?? + (connector.mcp_url + ? { + displayName: connector.name, + server_url: connector.mcp_url, + auth_type: + connector.auth_kind === 'mcp_oauth' ? 'oauth' : 'none', + oauth_scopes: connector.oauth_scopes.join(', '), + preset: true, + } + : undefined), }); return; } - const templateName = connector.tool_templates[0]; - if (!templateName) return; - const response = await userService.getAvailableTools(token); - const data = await response.json(); - const tool = (data.data as AvailableToolType[] | undefined)?.find( - (candidate) => candidate.name === templateName, - ); - if (!tool) return; - if (Object.keys(tool.configRequirements ?? {}).length === 0) { - // The OpenAPI connector: create the empty API tool and open it so - // the user can import a spec (today's API Tool flow). + if (connector.key === 'custom_openapi') { + // An OpenAPI tool starts empty; its spec import is today's API Tool + // screen on the Tools page. + const response = await userService.getAvailableTools(token); + const data = await response.json(); + const tool = (data.data as AvailableToolType[] | undefined)?.find( + (candidate) => candidate.name === 'api_tool', + ); + if (!tool) return; const created = await userService.createTool( { name: tool.name, @@ -94,43 +103,62 @@ export default function useConnectorLauncher({ } return; } - setActive({ kind: 'tool', tool }); + setActive({ + kind: 'wizard', + connector, + mode: options.mode ?? 'connect', + connectionId: options.connectionId, + }); }, [navigate, token], ); - const close = (state: ActiveState) => { + const afterMcpSave = async () => { + if (active?.kind !== 'mcp') return; + const connector = active.connector; + const list = await connectorsService.listConnections(token); + refresh(); + const connections = (list?.connections ?? []) as { + id: string; + connector_key: string; + updated_at: string | null; + }[]; + // The newest connection for this connector is the one just saved. + const saved = connections + .filter((c) => c.connector_key === connector.key) + .sort((a, b) => + (b.updated_at ?? '').localeCompare(a.updated_at ?? ''), + )[0]; + setActive( + saved + ? { kind: 'wizard', connector, mode: 'done', connectionId: saved.id } + : null, + ); + }; + + const closeMcp = (state: ActiveState) => { if (state === 'INACTIVE') setActive(null); }; const modals: ReactNode = ( <> - {active?.kind === 'source' && ( - setActive(null)} - initialIngestor={active.ingestor} - onSuccessfulUpload={finish} - selectUploadedDoc={false} - /> - )} - {active?.kind === 'tool' && ( - { + setActive(null); + refresh(); + }} /> )} {active?.kind === 'mcp' && ( )} diff --git a/frontend/src/conversation/ConnectToolCallBar.test.tsx b/frontend/src/conversation/ConnectToolCallBar.test.tsx new file mode 100644 index 00000000..2175639a --- /dev/null +++ b/frontend/src/conversation/ConnectToolCallBar.test.tsx @@ -0,0 +1,113 @@ +import { configureStore } from '@reduxjs/toolkit'; +import { act } from 'react'; +import { createRoot, type Root } from 'react-dom/client'; +import { Provider } from 'react-redux'; +import { MemoryRouter, Route, Routes } from 'react-router-dom'; + +vi.mock('react-i18next', () => ({ + useTranslation: () => ({ + t: (key: string, opts?: { name?: string }) => + opts?.name ? `${key}:${opts.name}` : key, + }), +})); + +vi.mock('../api/services/connectorsService', () => ({ + default: { + getCatalog: vi.fn().mockResolvedValue({ success: true, connectors: [] }), + listConnections: vi + .fn() + .mockResolvedValue({ success: true, connections: [] }), + }, +})); + +import connectorsReducer from '../connectors/connectorsSlice'; +import ConnectToolCallBar from './ConnectToolCallBar'; +import type { ToolCallsType } from './types'; + +Object.assign(globalThis, { IS_REACT_ACT_ENVIRONMENT: true }); + +const call = (status: string): ToolCallsType => ({ + tool_name: 'mcp_tool', + action_name: 'search_pages', + call_id: 'call-1', + arguments: {}, + status: 'awaiting_approval', + connection_required: { + connector_key: 'mcp:notion', + connector_name: 'Notion', + status, + }, +}); + +describe('ConnectToolCallBar', () => { + let root: Root; + let container: HTMLDivElement; + + beforeEach(() => { + container = document.createElement('div'); + document.body.appendChild(container); + root = createRoot(container); + }); + + afterEach(async () => { + await act(async () => root.unmount()); + container.remove(); + }); + + const render = async (toolCall: ToolCallsType, onToolAction = vi.fn()) => { + const store = configureStore({ + reducer: { + connectors: connectorsReducer, + preference: (state = { token: null }) => state, + }, + }); + await act(async () => { + root.render( + + + + + } + /> + DRAWER} /> + + + , + ); + }); + return onToolAction; + }; + + const button = (text: string) => + Array.from(container.querySelectorAll('button')).find( + (b) => b.textContent === text, + )!; + + it('names the service it needs', async () => { + await render(call('missing')); + expect(container.textContent).toContain( + 'conversation.toolApproval.connectTitle:Notion', + ); + expect(button('conversation.toolApproval.connect:Notion')).toBeDefined(); + }); + + it('skipping denies the pending call', async () => { + const onToolAction = await render(call('missing')); + await act(async () => button('conversation.toolApproval.skip').click()); + expect(onToolAction).toHaveBeenCalledWith('call-1', 'denied'); + }); + + it('an account that needs signing in again is healed from its drawer', async () => { + await render(call('reconnect_needed')); + await act(async () => + button('conversation.toolApproval.connect:Notion').click(), + ); + expect(document.body.textContent).toContain('DRAWER'); + }); +}); diff --git a/frontend/src/conversation/ConnectToolCallBar.tsx b/frontend/src/conversation/ConnectToolCallBar.tsx new file mode 100644 index 00000000..462fb372 --- /dev/null +++ b/frontend/src/conversation/ConnectToolCallBar.tsx @@ -0,0 +1,111 @@ +import { Plug } from 'lucide-react'; +import { useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import { useNavigate } from 'react-router-dom'; + +import { Button } from '../components/ui/button'; +import ConnectorIcon from '../connectors/ConnectorIcon'; +import { + loadConnectors, + selectConnectorCatalog, + selectConnectorsLoaded, +} from '../connectors/connectorsSlice'; +import useConnectorLauncher from '../connectors/useConnectorLauncher'; +import { selectToken } from '../preferences/preferenceSlice'; +import type { AppDispatch } from '../store'; +import type { ToolCallsType } from './types'; + +/** + * The approval card's "Connect to continue" variant: a paused call to a tool + * whose account needs signing in. Connecting keeps the call pending; Continue + * resumes it with the new connection, Skip denies it. + */ +export default function ConnectToolCallBar({ + toolCall, + onToolAction, +}: { + toolCall: ToolCallsType; + onToolAction?: (callId: string, decision: 'approved' | 'denied') => void; +}) { + const { t } = useTranslation(); + const dispatch = useDispatch(); + const navigate = useNavigate(); + const token = useSelector(selectToken); + const catalog = useSelector(selectConnectorCatalog); + const loaded = useSelector(selectConnectorsLoaded); + const [connected, setConnected] = useState(false); + const { launch, modals } = useConnectorLauncher({ + onConnected: () => setConnected(true), + }); + const required = toolCall.connection_required; + const connector = catalog.find((c) => c.key === required?.connector_key); + const name = + required?.connector_name || + connector?.name || + t('conversation.toolApproval.thisService'); + + useEffect(() => { + if (!loaded) dispatch(loadConnectors({ token })); + }, [loaded, dispatch, token]); + + const connect = () => { + // A member who has no account for the service connects one. An owner's + // existing account that needs signing in again is healed from its drawer, + // which reconnects that same connection for every tool and source. + if (connector && required?.status === 'missing') { + launch(connector); + return; + } + navigate( + `/settings/connectors${connector ? `?connector=${encodeURIComponent(connector.key)}` : ''}`, + ); + }; + + return ( +
+
+ {connector ? ( + + ) : ( + + )} + + {t('conversation.toolApproval.connectTitle', { + name, + interpolation: { escapeValue: false }, + })} + +
+
+ {connected ? ( + + ) : ( + + )} + +
+ {modals} +
+ ); +} diff --git a/frontend/src/conversation/ConversationBubble.tsx b/frontend/src/conversation/ConversationBubble.tsx index fba3e819..623e70dd 100644 --- a/frontend/src/conversation/ConversationBubble.tsx +++ b/frontend/src/conversation/ConversationBubble.tsx @@ -47,6 +47,7 @@ import { } from '../preferences/preferenceSlice'; import { isToolCallRunning } from '../utils/streamingStatusUtils'; import AnswerFlow from './AnswerFlow'; +import ConnectToolCallBar from './ConnectToolCallBar'; import { AnswerSegment } from './answerSegments'; import { deriveArtifactChips } from './artifactChips'; import { FEEDBACK, MESSAGE_TYPE, ResearchState } from './conversationModels'; @@ -473,10 +474,17 @@ const ConversationBubble = forwardRef< onOpenArtifact={onOpenArtifact} renderApproval={(toolCall: ToolCallsType) => (
- + {toolCall.connection_required ? ( + + ) : ( + + )}
)} renderWikiWrite={(toolCall: ToolCallsType, isLive: boolean) => ( diff --git a/frontend/src/conversation/types/index.ts b/frontend/src/conversation/types/index.ts index 730ca286..3d75137a 100644 --- a/frontend/src/conversation/types/index.ts +++ b/frontend/src/conversation/types/index.ts @@ -22,4 +22,16 @@ export type ToolCallsType = { // Remote-device tool calls carry the device id so the approval UI can // offer a "don't ask again" sticky-pattern action without a lookup. device_id?: string; + // A connection-backed tool whose account needs signing in pauses on a + // Connect card instead of an approval. Never carries an account or secret. + connection_required?: { + connector_key: string | null; + connector_name: string | null; + status: 'missing' | 'reconnect_needed' | 'disconnected' | 'error' | string; + }; + // Which connection a tool call used, for the connector's logo and name on + // its chip (never an account or a secret). + connector_key?: string | null; + connector_name?: string | null; + access?: 'read' | 'write' | null; }; diff --git a/frontend/src/events/dispatchEvent.ts b/frontend/src/events/dispatchEvent.ts index 3d8fc151..bcb0dc01 100644 --- a/frontend/src/events/dispatchEvent.ts +++ b/frontend/src/events/dispatchEvent.ts @@ -44,6 +44,9 @@ const KNOWN_TYPES: ReadonlySet = new Set([ 'graph.extract.progress', 'graph.extract.completed', 'graph.extract.failed', + // A connection's sign-in stopped working (connectors/service.py); + // consumed by ConnectionHealthToast via selectRecentEvents. + 'connection.reconnect_needed', ]); /** diff --git a/frontend/src/hooks/useDataInitializer.ts b/frontend/src/hooks/useDataInitializer.ts index 8cb7a3cb..b78501b3 100644 --- a/frontend/src/hooks/useDataInitializer.ts +++ b/frontend/src/hooks/useDataInitializer.ts @@ -2,6 +2,8 @@ import { useEffect } from 'react'; import { useDispatch, useSelector } from 'react-redux'; import userService from '../api/services/userService'; +import { setConnectorsEnabled } from '../connectors/connectorsSlice'; +import { claimLegacySessionTokens } from '../utils/providerUtils'; import { getDocs, getConversations, @@ -45,10 +47,19 @@ export default function useDataInitializer(isAuthLoading: boolean) { stt: config?.stt_available !== false, }), ); + // A backend from before connectors has no flag: hide the page. + dispatch(setConnectorsEnabled(config?.connectors_enabled === true)); }) .catch(() => undefined); }, [dispatch]); + // Connector sign-ins used to leave a session token in localStorage. Link + // each one to its server-side connection once, then forget it. + useEffect(() => { + if (isAuthLoading) return; + claimLegacySessionTokens(token); + }, [isAuthLoading, token]); + // Initialize documents useEffect(() => { // Skip if auth is still loading diff --git a/frontend/src/locale/de.json b/frontend/src/locale/de.json index ea2373ff..501d5ac0 100644 --- a/frontend/src/locale/de.json +++ b/frontend/src/locale/de.json @@ -1088,18 +1088,88 @@ "toolOn": "An", "toolOff": "Aus", "accountMenu": "Kontoaktionen", - "disconnect": "Trennen" + "disconnect": "Trennen", + "keyEnding": "Schlüssel endet auf {{hint}}", + "syncMore": "Weitere Inhalte synchronisieren", + "refreshTools": "Werkzeuge aktualisieren", + "noSources": "Aus dieser Verbindung wurde noch nichts synchronisiert.", + "remove": "Entfernen", + "refreshed": "Werkzeuge aktualisiert: {{added}} hinzugefügt, {{removed}} entfernt.", + "refreshFailed": "Die Werkzeuge konnten nicht aktualisiert werden." }, "permission": { "always": "Immer erlauben", "ask": "Freigabe nötig", - "off": "Aus" + "off": "Aus", + "label": "Berechtigung für {{action}}" }, "disconnect": { "title": "{{name}} trennen?", "body_zero": "Werkzeuge aus dieser Verbindung funktionieren erst wieder, wenn du neu verbindest.", "body_one": "Deine Quelle behält ihren Inhalt, wird aber nicht mehr synchronisiert. Werkzeuge aus dieser Verbindung funktionieren erst wieder, wenn du neu verbindest.", "body_other": "Deine {{count}} Quellen behalten ihren Inhalt, werden aber nicht mehr synchronisiert. Werkzeuge aus dieser Verbindung funktionieren erst wieder, wenn du neu verbindest." + }, + "fields": { + "token": "API-Schlüssel", + "aws_access_key_id": "Zugriffsschlüssel-ID", + "aws_secret_access_key": "Geheimer Zugriffsschlüssel", + "region": "Region", + "endpoint_url": "Eigene Endpunkt-URL", + "client_id": "Client-ID", + "client_secret": "Client-Secret", + "user_agent": "User-Agent", + "bucket": "Bucket", + "prefix": "Pfadpräfix", + "search_queries": "Suchanfragen", + "number_posts": "Anzahl der Beiträge", + "telegram_token": "Bot-Token", + "ntfy_token": "Zugriffstoken", + "postgres_token": "Verbindungszeichenfolge", + "brave_token": "API-Schlüssel" + }, + "wizard": { + "connectTitle": "{{name}} verbinden", + "reconnectTitle": "{{name}} neu verbinden", + "signIn": "Bei {{name}} anmelden", + "chooseWhatToSync": "Auswählen, was synchronisiert wird", + "skip": "Vorerst überspringen", + "syncFrequency": "Sync", + "doneTitle": "{{name}} ist verbunden", + "doneSummary": "Verbunden als {{account}}. {{sources}} werden synchronisiert, {{tools}} bereit.", + "doneSummaryNone": "Verbunden als {{account}}.", + "sourcesCount_one": "{{count}} Quelle", + "sourcesCount_other": "{{count}} Quellen", + "toolsCount_one": "{{count}} Werkzeug", + "toolsCount_other": "{{count}} Werkzeuge", + "toolsHeading": "Werkzeuge ({{count}})", + "tryInChat": "Im Chat ausprobieren", + "done": "Fertig", + "connectFailed": "Verbindung fehlgeschlagen. Prüfe die Angaben und versuche es erneut.", + "syncFailed": "Die Synchronisierung konnte nicht gestartet werden. Versuche es erneut.", + "connectedWithKey": "Verbunden mit dem Schlüssel, der auf {{hint}} endet.", + "doneCounts": "{{sources}} werden synchronisiert, {{tools}} bereit.", + "doneSources": "{{sources}} werden synchronisiert.", + "doneTools": "{{tools}} bereit." + }, + "remove": { + "title": "{{name}} entfernen?", + "description": "Die gespeicherte Anmeldung wird gelöscht. Wähle, was mit den Inhalten dieser Verbindung passiert.", + "sourcesLabel_one": "Ihre Quelle", + "sourcesLabel_other": "Ihre {{count}} Quellen", + "keepSources": "Inhalt behalten (Sync stoppen)", + "deleteSources": "Löschen", + "toolsLabel_one": "Ihr Werkzeug", + "toolsLabel_other": "Ihre {{count}} Werkzeuge", + "deleteTools": "Löschen", + "keepTools": "Behalten, ausgeschaltet", + "failed": "Die Verbindung konnte nicht entfernt werden. Versuche es erneut." + }, + "health": { + "reconnect": "Verbinde {{name}} neu, um weiter zu synchronisieren." + }, + "error": { + "decrypt": "Die gespeicherten Zugangsdaten konnten nicht entschlüsselt werden. Verbinde neu, um fortzufahren.", + "defaultKey": "Ein Admin muss ENCRYPTION_SECRET_KEY setzen, bevor Dienste verbunden werden können." } } }, @@ -1256,7 +1326,8 @@ }, "groupUploadWeb": "Upload und Web", "groupConnection": "Aus einer Verbindung", - "tileConnectedAs": "Verbunden als {{account}}" + "tileConnectedAs": "Verbunden als {{account}}", + "newCredentials": "Neue Zugangsdaten eingeben" }, "createAPIKey": { "label": "Neuen API-Schlüssel erstellen", @@ -1572,7 +1643,12 @@ "approveAlways": "Genehmigen, nicht mehr fragen", "deny": "Ablehnen", "details": "Details", - "denyReasonPlaceholder": "Optionaler Grund für die Ablehnung…" + "denyReasonPlaceholder": "Optionaler Grund für die Ablehnung…", + "connectTitle": "Dieses Werkzeug braucht dein {{name}}-Konto.", + "connect": "{{name}} verbinden", + "continue": "Weiter", + "skip": "Überspringen", + "thisService": "diesen Dienst" }, "research": { "title": "Recherche", @@ -2265,7 +2341,8 @@ "shared": "Geteilt", "connectTo": "Mit {{provider}} verbinden", "sessionExpiredFor": "Sitzung abgelaufen. Bitte verbinde dich erneut mit {{provider}}.", - "selectFilesFrom": "Dateien aus {{provider}} auswählen" + "selectFilesFrom": "Dateien aus {{provider}} auswählen", + "account": "Konto" }, "actionButtons": { "openNewChat": "Neuen Chat öffnen", diff --git a/frontend/src/locale/en.json b/frontend/src/locale/en.json index af1443a8..66f638a7 100644 --- a/frontend/src/locale/en.json +++ b/frontend/src/locale/en.json @@ -1094,18 +1094,88 @@ "toolOn": "On", "toolOff": "Off", "accountMenu": "Account actions", - "disconnect": "Disconnect" + "disconnect": "Disconnect", + "keyEnding": "Key ending {{hint}}", + "syncMore": "Sync more content", + "refreshTools": "Refresh tools", + "noSources": "Nothing synced from this connection yet.", + "remove": "Remove", + "refreshed": "Tools refreshed: {{added}} added, {{removed}} removed.", + "refreshFailed": "Could not refresh the tools." }, "permission": { "always": "Always allow", "ask": "Needs approval", - "off": "Off" + "off": "Off", + "label": "Permission for {{action}}" }, "disconnect": { "title": "Disconnect {{name}}?", "body_zero": "Tools from this connection stop working until you reconnect.", "body_one": "Your source keeps its content but stops syncing. Tools from this connection stop working until you reconnect.", "body_other": "Your {{count}} sources keep their content but stop syncing. Tools from this connection stop working until you reconnect." + }, + "fields": { + "token": "API key", + "aws_access_key_id": "Access key ID", + "aws_secret_access_key": "Secret access key", + "region": "Region", + "endpoint_url": "Custom endpoint URL", + "client_id": "Client ID", + "client_secret": "Client secret", + "user_agent": "User agent", + "bucket": "Bucket", + "prefix": "Path prefix", + "search_queries": "Search queries", + "number_posts": "Number of posts", + "telegram_token": "Bot token", + "ntfy_token": "Access token", + "postgres_token": "Connection string", + "brave_token": "API key" + }, + "wizard": { + "connectTitle": "Connect {{name}}", + "reconnectTitle": "Reconnect {{name}}", + "signIn": "Sign in to {{name}}", + "chooseWhatToSync": "Choose what to sync", + "skip": "Skip for now", + "syncFrequency": "Sync", + "doneTitle": "{{name}} is connected", + "doneSummary": "Connected as {{account}}. {{sources}} syncing, {{tools}} ready.", + "doneSummaryNone": "Connected as {{account}}.", + "sourcesCount_one": "{{count}} source", + "sourcesCount_other": "{{count}} sources", + "toolsCount_one": "{{count}} tool", + "toolsCount_other": "{{count}} tools", + "toolsHeading": "Tools ({{count}})", + "tryInChat": "Try it in chat", + "done": "Done", + "connectFailed": "Could not connect. Check the details and try again.", + "syncFailed": "Could not start the sync. Try again.", + "connectedWithKey": "Connected with the key ending {{hint}}.", + "doneCounts": "{{sources}} syncing, {{tools}} ready.", + "doneSources": "{{sources}} syncing.", + "doneTools": "{{tools}} ready." + }, + "remove": { + "title": "Remove {{name}}?", + "description": "The stored sign-in is deleted. Choose what happens to what this connection feeds.", + "sourcesLabel_one": "Its source", + "sourcesLabel_other": "Its {{count}} sources", + "keepSources": "Keep content (stop syncing)", + "deleteSources": "Delete", + "toolsLabel_one": "Its tool", + "toolsLabel_other": "Its {{count}} tools", + "deleteTools": "Delete", + "keepTools": "Keep, turned off", + "failed": "Could not remove the connection. Try again." + }, + "health": { + "reconnect": "Reconnect {{name}} to keep syncing." + }, + "error": { + "decrypt": "Stored credentials could not be decrypted. Reconnect to continue.", + "defaultKey": "An admin must set ENCRYPTION_SECRET_KEY before services can be connected." } } }, @@ -1262,7 +1332,8 @@ }, "groupUploadWeb": "Upload and web", "groupConnection": "From a connection", - "tileConnectedAs": "Connected as {{account}}" + "tileConnectedAs": "Connected as {{account}}", + "newCredentials": "Enter new credentials" }, "createAPIKey": { "label": "Create New API Key", @@ -1588,7 +1659,12 @@ "approveAlways": "Approve, don't ask again", "deny": "Deny", "details": "Details", - "denyReasonPlaceholder": "Optional reason for denying…" + "denyReasonPlaceholder": "Optional reason for denying…", + "connectTitle": "This tool needs your {{name}} account.", + "connect": "Connect {{name}}", + "continue": "Continue", + "skip": "Skip", + "thisService": "this service" }, "research": { "title": "Research", @@ -2300,7 +2376,8 @@ "shared": "Shared", "connectTo": "Connect to {{provider}}", "sessionExpiredFor": "Session expired. Please reconnect to {{provider}}.", - "selectFilesFrom": "Select Files from {{provider}}" + "selectFilesFrom": "Select Files from {{provider}}", + "account": "Account" }, "actionButtons": { "openNewChat": "Open New Chat", diff --git a/frontend/src/locale/es.json b/frontend/src/locale/es.json index 4f009258..d1c250ea 100644 --- a/frontend/src/locale/es.json +++ b/frontend/src/locale/es.json @@ -1088,18 +1088,88 @@ "toolOn": "Activada", "toolOff": "Desactivada", "accountMenu": "Acciones de la cuenta", - "disconnect": "Desconectar" + "disconnect": "Desconectar", + "keyEnding": "Clave que termina en {{hint}}", + "syncMore": "Sincronizar más contenido", + "refreshTools": "Actualizar herramientas", + "noSources": "Aún no se ha sincronizado nada desde esta conexión.", + "remove": "Eliminar", + "refreshed": "Herramientas actualizadas: {{added}} añadidas, {{removed}} eliminadas.", + "refreshFailed": "No se pudieron actualizar las herramientas." }, "permission": { "always": "Permitir siempre", "ask": "Requiere aprobación", - "off": "Desactivada" + "off": "Desactivada", + "label": "Permiso para {{action}}" }, "disconnect": { "title": "¿Desconectar {{name}}?", "body_zero": "Las herramientas de esta conexión dejarán de funcionar hasta que vuelvas a conectarla.", "body_one": "Tu fuente conserva su contenido pero deja de sincronizarse. Las herramientas de esta conexión dejarán de funcionar hasta que vuelvas a conectarla.", "body_other": "Tus {{count}} fuentes conservan su contenido pero dejan de sincronizarse. Las herramientas de esta conexión dejarán de funcionar hasta que vuelvas a conectarla." + }, + "fields": { + "token": "Clave de API", + "aws_access_key_id": "ID de clave de acceso", + "aws_secret_access_key": "Clave de acceso secreta", + "region": "Región", + "endpoint_url": "URL de endpoint personalizada", + "client_id": "ID de cliente", + "client_secret": "Secreto de cliente", + "user_agent": "User agent", + "bucket": "Bucket", + "prefix": "Prefijo de ruta", + "search_queries": "Consultas de búsqueda", + "number_posts": "Número de publicaciones", + "telegram_token": "Token del bot", + "ntfy_token": "Token de acceso", + "postgres_token": "Cadena de conexión", + "brave_token": "Clave de API" + }, + "wizard": { + "connectTitle": "Conectar {{name}}", + "reconnectTitle": "Reconectar {{name}}", + "signIn": "Iniciar sesión en {{name}}", + "chooseWhatToSync": "Elige qué sincronizar", + "skip": "Omitir por ahora", + "syncFrequency": "Sincronizar", + "doneTitle": "{{name}} está conectado", + "doneSummary": "Conectado como {{account}}. {{sources}} sincronizando, {{tools}} listas.", + "doneSummaryNone": "Conectado como {{account}}.", + "sourcesCount_one": "{{count}} fuente", + "sourcesCount_other": "{{count}} fuentes", + "toolsCount_one": "{{count}} herramienta", + "toolsCount_other": "{{count}} herramientas", + "toolsHeading": "Herramientas ({{count}})", + "tryInChat": "Probar en el chat", + "done": "Listo", + "connectFailed": "No se pudo conectar. Revisa los datos e inténtalo de nuevo.", + "syncFailed": "No se pudo iniciar la sincronización. Inténtalo de nuevo.", + "connectedWithKey": "Conectado con la clave que termina en {{hint}}.", + "doneCounts": "{{sources}} sincronizando, {{tools}} listas.", + "doneSources": "{{sources}} sincronizando.", + "doneTools": "{{tools}} listas." + }, + "remove": { + "title": "¿Eliminar {{name}}?", + "description": "Se borra el inicio de sesión guardado. Elige qué pasa con lo que alimenta esta conexión.", + "sourcesLabel_one": "Su fuente", + "sourcesLabel_other": "Sus {{count}} fuentes", + "keepSources": "Conservar el contenido (dejar de sincronizar)", + "deleteSources": "Eliminar", + "toolsLabel_one": "Su herramienta", + "toolsLabel_other": "Sus {{count}} herramientas", + "deleteTools": "Eliminar", + "keepTools": "Conservar, desactivadas", + "failed": "No se pudo eliminar la conexión. Inténtalo de nuevo." + }, + "health": { + "reconnect": "Reconecta {{name}} para seguir sincronizando." + }, + "error": { + "decrypt": "No se pudieron descifrar las credenciales guardadas. Reconecta para continuar.", + "defaultKey": "Un administrador debe definir ENCRYPTION_SECRET_KEY antes de poder conectar servicios." } } }, @@ -1256,7 +1326,8 @@ }, "groupUploadWeb": "Subida y web", "groupConnection": "Desde una conexión", - "tileConnectedAs": "Conectado como {{account}}" + "tileConnectedAs": "Conectado como {{account}}", + "newCredentials": "Introducir credenciales nuevas" }, "createAPIKey": { "label": "Crear Nueva Clave de API", @@ -1572,7 +1643,12 @@ "approveAlways": "Aprobar y no volver a preguntar", "deny": "Denegar", "details": "Detalles", - "denyReasonPlaceholder": "Motivo del rechazo (opcional)…" + "denyReasonPlaceholder": "Motivo del rechazo (opcional)…", + "connectTitle": "Esta herramienta necesita tu cuenta de {{name}}.", + "connect": "Conectar {{name}}", + "continue": "Continuar", + "skip": "Omitir", + "thisService": "este servicio" }, "research": { "title": "Investigación", @@ -2265,7 +2341,8 @@ "shared": "Compartido", "connectTo": "Conectar a {{provider}}", "sessionExpiredFor": "Sesión expirada. Por favor, reconecte a {{provider}}.", - "selectFilesFrom": "Seleccionar archivos de {{provider}}" + "selectFilesFrom": "Seleccionar archivos de {{provider}}", + "account": "Cuenta" }, "actionButtons": { "openNewChat": "Abrir nuevo chat", diff --git a/frontend/src/locale/jp.json b/frontend/src/locale/jp.json index 2595f2f3..9b408cda 100644 --- a/frontend/src/locale/jp.json +++ b/frontend/src/locale/jp.json @@ -1087,17 +1087,83 @@ "toolOn": "オン", "toolOff": "オフ", "accountMenu": "アカウントの操作", - "disconnect": "切断" + "disconnect": "切断", + "keyEnding": "末尾が {{hint}} のキー", + "syncMore": "さらに同期する", + "refreshTools": "ツールを更新", + "noSources": "この接続からはまだ何も同期されていません。", + "remove": "削除", + "refreshed": "ツールを更新しました: {{added}} 件追加、{{removed}} 件削除。", + "refreshFailed": "ツールを更新できませんでした。" }, "permission": { "always": "常に許可", "ask": "承認が必要", - "off": "オフ" + "off": "オフ", + "label": "{{action}} の権限" }, "disconnect": { "title": "{{name}} を切断しますか?", "body_zero": "この接続のツールは、再接続するまで使えなくなります。", "body_other": "{{count}} 件のソースはコンテンツを保持しますが、同期は停止します。この接続のツールは、再接続するまで使えなくなります。" + }, + "fields": { + "token": "API キー", + "aws_access_key_id": "アクセスキー ID", + "aws_secret_access_key": "シークレットアクセスキー", + "region": "リージョン", + "endpoint_url": "カスタムエンドポイント URL", + "client_id": "クライアント ID", + "client_secret": "クライアントシークレット", + "user_agent": "ユーザーエージェント", + "bucket": "バケット", + "prefix": "パスのプレフィックス", + "search_queries": "検索クエリ", + "number_posts": "投稿数", + "telegram_token": "ボットトークン", + "ntfy_token": "アクセストークン", + "postgres_token": "接続文字列", + "brave_token": "API キー" + }, + "wizard": { + "connectTitle": "{{name}} に接続", + "reconnectTitle": "{{name}} に再接続", + "signIn": "{{name}} にサインイン", + "chooseWhatToSync": "同期する内容を選択", + "skip": "今はスキップ", + "syncFrequency": "同期", + "doneTitle": "{{name}} に接続しました", + "doneSummary": "{{account}} として接続しました。{{sources}}を同期中、{{tools}}を利用できます。", + "doneSummaryNone": "{{account}} として接続しました。", + "sourcesCount_other": "{{count}} 件のソース", + "toolsCount_other": "{{count}} 個のツール", + "toolsHeading": "ツール ({{count}})", + "tryInChat": "チャットで試す", + "done": "完了", + "connectFailed": "接続できませんでした。入力内容を確認して、もう一度お試しください。", + "syncFailed": "同期を開始できませんでした。もう一度お試しください。", + "connectedWithKey": "末尾が {{hint}} のキーで接続しました。", + "doneCounts": "{{sources}}を同期中、{{tools}}を利用できます。", + "doneSources": "{{sources}}を同期中です。", + "doneTools": "{{tools}}を利用できます。" + }, + "remove": { + "title": "{{name}} を削除しますか?", + "description": "保存されたサインイン情報は削除されます。この接続から提供されているものをどうするか選んでください。", + "sourcesLabel_other": "{{count}} 件のソース", + "keepSources": "コンテンツを保持(同期を停止)", + "deleteSources": "削除", + "toolsLabel_other": "{{count}} 個のツール", + "deleteTools": "削除", + "keepTools": "保持してオフにする", + "failed": "接続を削除できませんでした。もう一度お試しください。" + }, + "health": { + "reconnect": "同期を続けるには {{name}} を再接続してください。" + }, + "error": { + "decrypt": "保存された認証情報を復号できませんでした。続行するには再接続してください。", + "defaultKey": "サービスを接続する前に、管理者が ENCRYPTION_SECRET_KEY を設定する必要があります。" } } }, @@ -1254,7 +1320,8 @@ }, "groupUploadWeb": "アップロードとウェブ", "groupConnection": "接続から", - "tileConnectedAs": "{{account}} として接続中" + "tileConnectedAs": "{{account}} として接続中", + "newCredentials": "新しい認証情報を入力" }, "createAPIKey": { "label": "新しいAPIキーを作成", @@ -1568,7 +1635,12 @@ "approveAlways": "承認し、今後は確認しない", "deny": "拒否", "details": "詳細", - "denyReasonPlaceholder": "拒否の理由(任意)…" + "denyReasonPlaceholder": "拒否の理由(任意)…", + "connectTitle": "このツールには {{name}} アカウントが必要です。", + "connect": "{{name}} に接続", + "continue": "続行", + "skip": "スキップ", + "thisService": "このサービス" }, "research": { "title": "リサーチ", @@ -2258,7 +2330,8 @@ "shared": "共有", "connectTo": "{{provider}}に接続", "sessionExpiredFor": "セッションが期限切れです。{{provider}}に再接続してください。", - "selectFilesFrom": "{{provider}}からファイルを選択" + "selectFilesFrom": "{{provider}}からファイルを選択", + "account": "アカウント" }, "actionButtons": { "openNewChat": "新しいチャットを開く", diff --git a/frontend/src/locale/ru.json b/frontend/src/locale/ru.json index b3cb6d3d..b18c6471 100644 --- a/frontend/src/locale/ru.json +++ b/frontend/src/locale/ru.json @@ -1128,12 +1128,20 @@ "toolOn": "Вкл.", "toolOff": "Выкл.", "accountMenu": "Действия с аккаунтом", - "disconnect": "Отключить" + "disconnect": "Отключить", + "keyEnding": "Ключ, оканчивающийся на {{hint}}", + "syncMore": "Синхронизировать ещё", + "refreshTools": "Обновить инструменты", + "noSources": "Из этого подключения пока ничего не синхронизировано.", + "remove": "Удалить", + "refreshed": "Инструменты обновлены: добавлено {{added}}, удалено {{removed}}.", + "refreshFailed": "Не удалось обновить инструменты." }, "permission": { "always": "Всегда разрешать", "ask": "Нужно одобрение", - "off": "Выкл." + "off": "Выкл.", + "label": "Разрешение для {{action}}" }, "disconnect": { "title": "Отключить {{name}}?", @@ -1142,6 +1150,76 @@ "body_few": "Ваши {{count}} источника сохранят контент, но перестанут синхронизироваться. Инструменты из этого подключения перестанут работать, пока вы не переподключитесь.", "body_many": "Ваши {{count}} источников сохранят контент, но перестанут синхронизироваться. Инструменты из этого подключения перестанут работать, пока вы не переподключитесь.", "body_other": "Ваши {{count}} источника сохранят контент, но перестанут синхронизироваться. Инструменты из этого подключения перестанут работать, пока вы не переподключитесь." + }, + "fields": { + "token": "API-ключ", + "aws_access_key_id": "ID ключа доступа", + "aws_secret_access_key": "Секретный ключ доступа", + "region": "Регион", + "endpoint_url": "Свой URL эндпоинта", + "client_id": "ID клиента", + "client_secret": "Секрет клиента", + "user_agent": "User agent", + "bucket": "Бакет", + "prefix": "Префикс пути", + "search_queries": "Поисковые запросы", + "number_posts": "Количество постов", + "telegram_token": "Токен бота", + "ntfy_token": "Токен доступа", + "postgres_token": "Строка подключения", + "brave_token": "API-ключ" + }, + "wizard": { + "connectTitle": "Подключить {{name}}", + "reconnectTitle": "Переподключить {{name}}", + "signIn": "Войти в {{name}}", + "chooseWhatToSync": "Выберите, что синхронизировать", + "skip": "Пока пропустить", + "syncFrequency": "Синхронизация", + "doneTitle": "{{name}} подключён", + "doneSummary": "Подключено как {{account}}. Синхронизируется: {{sources}}, готово: {{tools}}.", + "doneSummaryNone": "Подключено как {{account}}.", + "sourcesCount_one": "{{count}} источник", + "sourcesCount_few": "{{count}} источника", + "sourcesCount_many": "{{count}} источников", + "sourcesCount_other": "{{count}} источника", + "toolsCount_one": "{{count}} инструмент", + "toolsCount_few": "{{count}} инструмента", + "toolsCount_many": "{{count}} инструментов", + "toolsCount_other": "{{count}} инструмента", + "toolsHeading": "Инструменты ({{count}})", + "tryInChat": "Попробовать в чате", + "done": "Готово", + "connectFailed": "Не удалось подключиться. Проверьте данные и попробуйте ещё раз.", + "syncFailed": "Не удалось запустить синхронизацию. Попробуйте ещё раз.", + "connectedWithKey": "Подключено ключом, оканчивающимся на {{hint}}.", + "doneCounts": "Синхронизируется: {{sources}}, готово: {{tools}}.", + "doneSources": "Синхронизируется: {{sources}}.", + "doneTools": "Готово: {{tools}}." + }, + "remove": { + "title": "Удалить {{name}}?", + "description": "Сохранённый вход будет удалён. Выберите, что станет с тем, что даёт это подключение.", + "sourcesLabel_one": "Его источник", + "sourcesLabel_few": "Его {{count}} источника", + "sourcesLabel_many": "Его {{count}} источников", + "sourcesLabel_other": "Его {{count}} источника", + "keepSources": "Оставить контент (остановить синхронизацию)", + "deleteSources": "Удалить", + "toolsLabel_one": "Его инструмент", + "toolsLabel_few": "Его {{count}} инструмента", + "toolsLabel_many": "Его {{count}} инструментов", + "toolsLabel_other": "Его {{count}} инструмента", + "deleteTools": "Удалить", + "keepTools": "Оставить выключенными", + "failed": "Не удалось удалить подключение. Попробуйте ещё раз." + }, + "health": { + "reconnect": "Переподключите {{name}}, чтобы синхронизация продолжилась." + }, + "error": { + "decrypt": "Не удалось расшифровать сохранённые учётные данные. Переподключитесь, чтобы продолжить.", + "defaultKey": "Администратор должен задать ENCRYPTION_SECRET_KEY, прежде чем можно будет подключать сервисы." } } }, @@ -1298,7 +1376,8 @@ }, "groupUploadWeb": "Загрузка и веб", "groupConnection": "Из подключения", - "tileConnectedAs": "Подключено как {{account}}" + "tileConnectedAs": "Подключено как {{account}}", + "newCredentials": "Ввести новые учётные данные" }, "createAPIKey": { "label": "Создать новый API ключ", @@ -1618,7 +1697,12 @@ "approveAlways": "Одобрить и больше не спрашивать", "deny": "Отклонить", "details": "Подробности", - "denyReasonPlaceholder": "Причина отказа (необязательно)…" + "denyReasonPlaceholder": "Причина отказа (необязательно)…", + "connectTitle": "Этому инструменту нужен ваш аккаунт {{name}}.", + "connect": "Подключить {{name}}", + "continue": "Продолжить", + "skip": "Пропустить", + "thisService": "этот сервис" }, "research": { "title": "Исследование", @@ -2325,7 +2409,8 @@ "shared": "Общие", "connectTo": "Подключиться к {{provider}}", "sessionExpiredFor": "Сеанс истек. Пожалуйста, переподключитесь к {{provider}}.", - "selectFilesFrom": "Выберите файлы из {{provider}}" + "selectFilesFrom": "Выберите файлы из {{provider}}", + "account": "Аккаунт" }, "actionButtons": { "openNewChat": "Открыть новый чат", diff --git a/frontend/src/locale/zh-TW.json b/frontend/src/locale/zh-TW.json index 183deb66..67ea0cfc 100644 --- a/frontend/src/locale/zh-TW.json +++ b/frontend/src/locale/zh-TW.json @@ -1087,17 +1087,83 @@ "toolOn": "開啟", "toolOff": "關閉", "accountMenu": "帳號操作", - "disconnect": "中斷連線" + "disconnect": "中斷連線", + "keyEnding": "結尾為 {{hint}} 的金鑰", + "syncMore": "同步更多內容", + "refreshTools": "重新整理工具", + "noSources": "尚未從此連線同步任何內容。", + "remove": "移除", + "refreshed": "工具已重新整理:新增 {{added}} 個,移除 {{removed}} 個。", + "refreshFailed": "無法重新整理工具。" }, "permission": { "always": "一律允許", "ask": "需要核准", - "off": "關閉" + "off": "關閉", + "label": "{{action}} 的權限" }, "disconnect": { "title": "要中斷 {{name}} 的連線嗎?", "body_zero": "在重新連線之前,此連線的工具將無法使用。", "body_other": "你的 {{count}} 個來源會保留內容,但會停止同步。在重新連線之前,此連線的工具將無法使用。" + }, + "fields": { + "token": "API 金鑰", + "aws_access_key_id": "存取金鑰 ID", + "aws_secret_access_key": "秘密存取金鑰", + "region": "區域", + "endpoint_url": "自訂端點 URL", + "client_id": "用戶端 ID", + "client_secret": "用戶端密鑰", + "user_agent": "User agent", + "bucket": "儲存貯體", + "prefix": "路徑前綴", + "search_queries": "搜尋詞", + "number_posts": "貼文數量", + "telegram_token": "機器人權杖", + "ntfy_token": "存取權杖", + "postgres_token": "連線字串", + "brave_token": "API 金鑰" + }, + "wizard": { + "connectTitle": "連線 {{name}}", + "reconnectTitle": "重新連線 {{name}}", + "signIn": "登入 {{name}}", + "chooseWhatToSync": "選擇要同步的內容", + "skip": "暫時略過", + "syncFrequency": "同步", + "doneTitle": "{{name}} 已連線", + "doneSummary": "已以 {{account}} 連線。{{sources}}同步中,{{tools}}已就緒。", + "doneSummaryNone": "已以 {{account}} 連線。", + "sourcesCount_other": "{{count}} 個來源", + "toolsCount_other": "{{count}} 個工具", + "toolsHeading": "工具({{count}})", + "tryInChat": "在聊天中試試", + "done": "完成", + "connectFailed": "無法連線。請檢查資料後再試一次。", + "syncFailed": "無法開始同步。請再試一次。", + "connectedWithKey": "已使用結尾為 {{hint}} 的金鑰連線。", + "doneCounts": "{{sources}}同步中,{{tools}}已就緒。", + "doneSources": "{{sources}}同步中。", + "doneTools": "{{tools}}已就緒。" + }, + "remove": { + "title": "要移除 {{name}} 嗎?", + "description": "儲存的登入資訊將被刪除。請選擇如何處理此連線提供的內容。", + "sourcesLabel_other": "它的 {{count}} 個來源", + "keepSources": "保留內容(停止同步)", + "deleteSources": "刪除", + "toolsLabel_other": "它的 {{count}} 個工具", + "deleteTools": "刪除", + "keepTools": "保留並關閉", + "failed": "無法移除此連線。請再試一次。" + }, + "health": { + "reconnect": "重新連線 {{name}} 以繼續同步。" + }, + "error": { + "decrypt": "無法解密已儲存的憑證。請重新連線以繼續。", + "defaultKey": "管理員必須先設定 ENCRYPTION_SECRET_KEY,才能連線服務。" } } }, @@ -1254,7 +1320,8 @@ }, "groupUploadWeb": "上傳和網頁", "groupConnection": "來自連線", - "tileConnectedAs": "已以 {{account}} 連線" + "tileConnectedAs": "已以 {{account}} 連線", + "newCredentials": "輸入新的憑證" }, "createAPIKey": { "label": "建立新的 API 金鑰", @@ -1568,7 +1635,12 @@ "approveAlways": "核准,不再詢問", "deny": "拒絕", "details": "詳細資料", - "denyReasonPlaceholder": "拒絕原因(選填)…" + "denyReasonPlaceholder": "拒絕原因(選填)…", + "connectTitle": "此工具需要你的 {{name}} 帳號。", + "connect": "連線 {{name}}", + "continue": "繼續", + "skip": "略過", + "thisService": "此服務" }, "research": { "title": "研究", @@ -2258,7 +2330,8 @@ "shared": "共用", "connectTo": "連接到 {{provider}}", "sessionExpiredFor": "工作階段已過期。請重新連接到 {{provider}}。", - "selectFilesFrom": "從 {{provider}} 選擇檔案" + "selectFilesFrom": "從 {{provider}} 選擇檔案", + "account": "帳號" }, "actionButtons": { "openNewChat": "開啟新聊天", diff --git a/frontend/src/locale/zh.json b/frontend/src/locale/zh.json index 29b4880b..6fd94305 100644 --- a/frontend/src/locale/zh.json +++ b/frontend/src/locale/zh.json @@ -1087,17 +1087,83 @@ "toolOn": "开启", "toolOff": "关闭", "accountMenu": "账号操作", - "disconnect": "断开连接" + "disconnect": "断开连接", + "keyEnding": "以 {{hint}} 结尾的密钥", + "syncMore": "同步更多内容", + "refreshTools": "刷新工具", + "noSources": "尚未从此连接同步任何内容。", + "remove": "移除", + "refreshed": "工具已刷新:新增 {{added}} 个,移除 {{removed}} 个。", + "refreshFailed": "无法刷新工具。" }, "permission": { "always": "始终允许", "ask": "需要批准", - "off": "关闭" + "off": "关闭", + "label": "{{action}} 的权限" }, "disconnect": { "title": "断开 {{name}}?", "body_zero": "在重新连接之前,此连接的工具将无法使用。", "body_other": "你的 {{count}} 个来源会保留内容,但将停止同步。在重新连接之前,此连接的工具将无法使用。" + }, + "fields": { + "token": "API 密钥", + "aws_access_key_id": "访问密钥 ID", + "aws_secret_access_key": "秘密访问密钥", + "region": "区域", + "endpoint_url": "自定义端点 URL", + "client_id": "客户端 ID", + "client_secret": "客户端密钥", + "user_agent": "User agent", + "bucket": "存储桶", + "prefix": "路径前缀", + "search_queries": "搜索词", + "number_posts": "帖子数量", + "telegram_token": "机器人令牌", + "ntfy_token": "访问令牌", + "postgres_token": "连接字符串", + "brave_token": "API 密钥" + }, + "wizard": { + "connectTitle": "连接 {{name}}", + "reconnectTitle": "重新连接 {{name}}", + "signIn": "登录 {{name}}", + "chooseWhatToSync": "选择要同步的内容", + "skip": "暂时跳过", + "syncFrequency": "同步", + "doneTitle": "{{name}} 已连接", + "doneSummary": "已作为 {{account}} 连接。{{sources}}正在同步,{{tools}}已就绪。", + "doneSummaryNone": "已作为 {{account}} 连接。", + "sourcesCount_other": "{{count}} 个来源", + "toolsCount_other": "{{count}} 个工具", + "toolsHeading": "工具({{count}})", + "tryInChat": "在聊天中试试", + "done": "完成", + "connectFailed": "无法连接。请检查信息后重试。", + "syncFailed": "无法开始同步。请重试。", + "connectedWithKey": "已使用以 {{hint}} 结尾的密钥连接。", + "doneCounts": "{{sources}}正在同步,{{tools}}已就绪。", + "doneSources": "{{sources}}正在同步。", + "doneTools": "{{tools}}已就绪。" + }, + "remove": { + "title": "移除 {{name}}?", + "description": "保存的登录信息将被删除。请选择如何处理此连接提供的内容。", + "sourcesLabel_other": "它的 {{count}} 个来源", + "keepSources": "保留内容(停止同步)", + "deleteSources": "删除", + "toolsLabel_other": "它的 {{count}} 个工具", + "deleteTools": "删除", + "keepTools": "保留并关闭", + "failed": "无法移除此连接。请重试。" + }, + "health": { + "reconnect": "重新连接 {{name}} 以继续同步。" + }, + "error": { + "decrypt": "无法解密保存的凭据。请重新连接以继续。", + "defaultKey": "管理员必须先设置 ENCRYPTION_SECRET_KEY,才能连接服务。" } } }, @@ -1254,7 +1320,8 @@ }, "groupUploadWeb": "上传和网页", "groupConnection": "来自连接", - "tileConnectedAs": "已作为 {{account}} 连接" + "tileConnectedAs": "已作为 {{account}} 连接", + "newCredentials": "输入新的凭据" }, "createAPIKey": { "label": "创建新的 API 密钥", @@ -1568,7 +1635,12 @@ "approveAlways": "批准,不再询问", "deny": "拒绝", "details": "详情", - "denyReasonPlaceholder": "拒绝原因(可选)…" + "denyReasonPlaceholder": "拒绝原因(可选)…", + "connectTitle": "此工具需要你的 {{name}} 账号。", + "connect": "连接 {{name}}", + "continue": "继续", + "skip": "跳过", + "thisService": "此服务" }, "research": { "title": "研究", @@ -2258,7 +2330,8 @@ "shared": "共享", "connectTo": "连接到 {{provider}}", "sessionExpiredFor": "会话已过期。请重新连接到 {{provider}}。", - "selectFilesFrom": "从 {{provider}} 选择文件" + "selectFilesFrom": "从 {{provider}} 选择文件", + "account": "账号" }, "actionButtons": { "openNewChat": "打开新聊天", diff --git a/frontend/src/modals/AddToolModal.tsx b/frontend/src/modals/AddToolModal.tsx index 421831fe..0e79c97c 100644 --- a/frontend/src/modals/AddToolModal.tsx +++ b/frontend/src/modals/AddToolModal.tsx @@ -1,7 +1,7 @@ import { ArrowRight, Plus } from 'lucide-react'; import React from 'react'; import { useTranslation } from 'react-i18next'; -import { useSelector } from 'react-redux'; +import { useDispatch, useSelector } from 'react-redux'; import { Link } from 'react-router-dom'; import userService from '../api/services/userService'; @@ -12,7 +12,13 @@ import { Card, CardDescription, CardTitle } from '../components/ui/card'; import { Modal } from '../components/ui/modal'; import { SectionHeader } from '../components/ui/section-header'; import { useLoaderState } from '../hooks'; +import { + loadConnectors, + selectConnectorCatalog, +} from '../connectors/connectorsSlice'; +import useConnectorLauncher from '../connectors/useConnectorLauncher'; import PairDeviceModal from '../settings/PairDeviceModal'; +import type { AppDispatch } from '../store'; import { ActiveState } from '../models/misc'; import { selectToken } from '../preferences/preferenceSlice'; import ConfigToolModal from './ConfigToolModal'; @@ -35,6 +41,11 @@ export default function AddToolModal({ }) { const { t } = useTranslation(); const token = useSelector(selectToken); + const dispatch = useDispatch(); + const catalog = useSelector(selectConnectorCatalog); + const { launch, modals: connectModals } = useConnectorLauncher({ + onConnected: getUserTools, + }); const [availableTools, setAvailableTools] = React.useState< AvailableToolType[] >([]); @@ -71,6 +82,15 @@ export default function AddToolModal({ setPairModalState('ACTIVE'); return; } + // A service's tool is added by connecting the service: the key goes on + // a connection and the tool is created with write actions needing + // approval. + const connector = catalog.find((c) => c.key === tool.connector_key); + if (tool.group === 'service' && connector) { + setModalState('INACTIVE'); + launch(connector); + return; + } if (Object.keys(tool.configRequirements).length === 0) { userService .createTool( @@ -108,7 +128,10 @@ export default function AddToolModal({ }; React.useEffect(() => { - if (modalState === 'ACTIVE') getAvailableTools(); + if (modalState === 'ACTIVE') { + getAvailableTools(); + dispatch(loadConnectors({ token })); + } }, [modalState]); return ( @@ -210,6 +233,7 @@ export default function AddToolModal({ tool={selectedTool} getUserTools={getUserTools} /> + {connectModals} diff --git a/frontend/src/navigation/SectionNav.tsx b/frontend/src/navigation/SectionNav.tsx index 1b2ef85a..71abcfe0 100644 --- a/frontend/src/navigation/SectionNav.tsx +++ b/frontend/src/navigation/SectionNav.tsx @@ -1,9 +1,11 @@ import { ArrowLeft } from 'lucide-react'; import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; import { Link } from 'react-router-dom'; import { Button } from '@/components/ui/button'; +import { selectConnectorsEnabled } from '../connectors/connectorsSlice'; import { getVisibleGroups, type Section, type SectionItem } from './sections'; import { useSidebarLevel } from './SidebarLevelProvider'; @@ -35,7 +37,11 @@ export default function SectionNav({ }: SectionNavProps) { const { t } = useTranslation(); const { goToLevel } = useSidebarLevel(); - const groups = getVisibleGroups(section, { isAdmin }); + const connectorsEnabled = useSelector(selectConnectorsEnabled); + const groups = getVisibleGroups(section, { + isAdmin, + features: { connectors: connectorsEnabled }, + }); const sectionTitle = section.title ?? t(section.titleKey); const renderItem = (item: SectionItem) => { diff --git a/frontend/src/navigation/SectionPills.tsx b/frontend/src/navigation/SectionPills.tsx index 4a4d10f0..29e6acaa 100644 --- a/frontend/src/navigation/SectionPills.tsx +++ b/frontend/src/navigation/SectionPills.tsx @@ -6,6 +6,7 @@ import { Button } from '@/components/ui/button'; import { cn } from '@/lib/utils'; import { selectIsAdmin } from '@/preferences/preferenceSlice'; +import { selectConnectorsEnabled } from '../connectors/connectorsSlice'; import { getVisibleGroups } from './sections'; import { useSectionContext } from './useSectionContext'; @@ -23,11 +24,13 @@ export default function SectionPills({ className }: { className?: string }) { const { t } = useTranslation(); const { section, item } = useSectionContext(); const isAdmin = useSelector(selectIsAdmin); + const connectorsEnabled = useSelector(selectConnectorsEnabled); if (!section) return null; - const items = getVisibleGroups(section, { isAdmin }).flatMap( - (group) => group.items, - ); + const items = getVisibleGroups(section, { + isAdmin, + features: { connectors: connectorsEnabled }, + }).flatMap((group) => group.items); if (items.length < 2) return null; return ( diff --git a/frontend/src/navigation/sections.ts b/frontend/src/navigation/sections.ts index 0334f18c..6f5c680b 100644 --- a/frontend/src/navigation/sections.ts +++ b/frontend/src/navigation/sections.ts @@ -42,6 +42,8 @@ export type SectionItem = { adminOnly?: boolean; /** Jumps to a different section rather than navigating within this one. */ leavesSection?: boolean; + /** Hidden when the backend reports this feature off (`/api/config`). */ + feature?: 'connectors'; }; /** Items sharing a heading in the nav. */ @@ -121,6 +123,7 @@ export const SETTINGS_SECTION: Section = { path: '/settings/connectors', labelKey: 'settings.connectors.label', icon: Plug, + feature: 'connectors', }, { key: 'tools', @@ -355,12 +358,22 @@ export function getSectionItems( /** Groups with admin-only entries removed, dropping any group left empty. */ export function getVisibleGroups( section: Section, - { isAdmin = true }: { isAdmin?: boolean } = {}, + { + isAdmin = true, + features = {}, + }: { + isAdmin?: boolean; + features?: Partial, boolean>>; + } = {}, ): SectionGroup[] { return section.groups .map((group) => ({ ...group, - items: group.items.filter((item) => !item.adminOnly || isAdmin), + items: group.items.filter( + (item) => + (!item.adminOnly || isAdmin) && + (!item.feature || features[item.feature] !== false), + ), })) .filter((group) => group.items.length > 0); } diff --git a/frontend/src/notifications/ConnectionHealthToast.tsx b/frontend/src/notifications/ConnectionHealthToast.tsx new file mode 100644 index 00000000..987ef7e1 --- /dev/null +++ b/frontend/src/notifications/ConnectionHealthToast.tsx @@ -0,0 +1,116 @@ +import { X } from 'lucide-react'; +import { useCallback, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDispatch, useSelector } from 'react-redux'; +import { Link } from 'react-router-dom'; + +import { Button } from '../components/ui/button'; +import { + Toast, + ToastActions, + ToastFooter, + ToastHeader, + ToastTitle, +} from '../components/ui/toast'; +import { loadConnectors } from '../connectors/connectorsSlice'; +import { selectToken } from '../preferences/preferenceSlice'; +import type { AppDispatch } from '../store'; +import { + dismissShareNotification, + selectDismissedShareNotifications, + selectRecentEvents, + type SSEEvent, +} from './notificationsSlice'; + +// Backlog replay re-delivers up to a day of events; an old one is noise. +const MAX_AGE_MS = 24 * 60 * 60 * 1000; +const MAX_VISIBLE = 2; + +/** + * "Reconnect Google Drive to keep syncing": a connection whose sign-in + * stopped working (``connection.reconnect_needed``). Stays until the user + * reconnects or dismisses it, since syncing is paused until then. Shares + * the team notifications' persisted dismissals, so a reload does not pop it + * again. + */ +export default function ConnectionHealthToast() { + const dispatch = useDispatch(); + const { t } = useTranslation(); + const token = useSelector(selectToken); + const events = useSelector(selectRecentEvents); + const dismissed = useSelector(selectDismissedShareNotifications); + const dismissedSet = useMemo(() => new Set(dismissed), [dismissed]); + + const onDismiss = useCallback( + (id: string) => dispatch(dismissShareNotification(id)), + [dispatch], + ); + + const now = Date.now(); + const visible: SSEEvent[] = []; + const seenConnections = new Set(); + for (const event of events) { + if (event.type !== 'connection.reconnect_needed') continue; + if (!event.id || dismissedSet.has(event.id)) continue; + if (event.ts) { + const age = now - Date.parse(event.ts); + if (Number.isFinite(age) && age > MAX_AGE_MS) continue; + } + const connectionId = String(event.scope?.id ?? event.id); + if (seenConnections.has(connectionId)) continue; + seenConnections.add(connectionId); + visible.push(event); + if (visible.length >= MAX_VISIBLE) break; + } + + // Refresh the badges on the Connectors, Sources and Tools pages. + const newest = visible[0]?.id; + useEffect(() => { + if (newest) dispatch(loadConnectors({ token })); + }, [newest, dispatch, token]); + + if (visible.length === 0) return null; + + return ( + <> + {visible.map((event) => { + const payload = (event.payload ?? {}) as Record; + const name = String(payload.name ?? ''); + const key = String(payload.connector_key ?? ''); + return ( + + + + {t('settings.connectors.health.reconnect', { + name, + interpolation: { escapeValue: false }, + })} + + + + + + + + + + ); + })} + + ); +} diff --git a/frontend/src/settings/Connectors.tsx b/frontend/src/settings/Connectors.tsx index cf2bdfd2..8656327b 100644 --- a/frontend/src/settings/Connectors.tsx +++ b/frontend/src/settings/Connectors.tsx @@ -209,9 +209,9 @@ export default function Connectors() { { + onConnect={(connector, options) => { closeDrawer(); - launch(connector); + launch(connector, options); }} /> {modals} diff --git a/frontend/src/settings/Sources.tsx b/frontend/src/settings/Sources.tsx index a95c53ed..2289195b 100644 --- a/frontend/src/settings/Sources.tsx +++ b/frontend/src/settings/Sources.tsx @@ -218,42 +218,13 @@ export default function Sources({ }); }; - const getConnectorProvider = async (doc: Doc): Promise => { - if (doc.provider) { - return doc.provider; - } - if (!doc.id) { - return null; - } - try { - const directoryResponse = await userService.getDirectoryStructure( - doc.id, - token, - ); - const directoryData = await directoryResponse.json(); - return directoryData?.provider ?? null; - } catch (error) { - console.error('Error fetching connector provider:', error); - return null; - } - }; - const handleSyncNow = async (doc: Doc) => { if (!doc.id) { return; } try { if (doc.type?.startsWith('connector')) { - const provider = await getConnectorProvider(doc); - if (!provider) { - console.error('Sync now failed: provider not found'); - return; - } - const response = await userService.syncConnector( - doc.id, - provider, - token, - ); + const response = await userService.syncConnector(doc.id, token); const data = await response.json(); if (!data.success) { console.error('Sync now failed:', data.error || data.message); diff --git a/frontend/src/upload/Upload.tsx b/frontend/src/upload/Upload.tsx index 9c02bacb..41c95e86 100644 --- a/frontend/src/upload/Upload.tsx +++ b/frontend/src/upload/Upload.tsx @@ -11,7 +11,6 @@ import type { RootState } from '../store'; import userService from '../api/services/userService'; import modelService from '../api/services/modelService'; import type { Model } from '../models/types'; -import { getSessionToken } from '../utils/providerUtils'; import { Button } from '../components/ui/button'; import { Input } from '../components/ui/input'; import { FormField as UiFormField } from '../components/ui/form-field'; @@ -113,6 +112,9 @@ function Upload({ // File picker state const [selectedFiles, setSelectedFiles] = useState([]); const [selectedFolders, setSelectedFolders] = useState([]); + // The connection (account) the source syncs from. Pickers report it; S3 + // and Reddit pick it here ('' means "enter new credentials"). + const [connectionId, setConnectionId] = useState(null); useEffect(() => { let cancelled = false; @@ -155,11 +157,39 @@ function Upload({ if (!ingestorSchema) return null; const schema: FormField[] = ingestorSchema.fields; - const generalFields = schema.filter((field: FormField) => !field.advanced); + const generalFields = schema.filter( + (field: FormField) => + !field.advanced && !(usingSavedKeys && credentialKeys.has(field.name)), + ); const advancedFields = schema.filter((field: FormField) => field.advanced); return (
+ {keyAccounts.length > 0 && ( + + + + )}
{generalFields.map((field: FormField) => renderField(field))}
@@ -353,6 +383,7 @@ function Upload({ setSelectedFolders(selectedFolderIds); }} onFirstPickName={prefillName} + onConnectionChange={setConnectionId} provider={ingestor.type as unknown as string} token={token} initialSelectedFiles={selectedFiles} @@ -373,6 +404,7 @@ function Upload({ setSelectedFolders(selectedFolderIds); }} onFirstPickName={prefillName} + onConnectionChange={setConnectionId} token={token} /> ) : ( @@ -386,6 +418,7 @@ function Upload({ setSelectedFolders(selectedFolderIds); }} onFirstPickName={prefillName} + onConnectionChange={setConnectionId} provider="google_drive" token={token} initialSelectedFiles={selectedFiles} @@ -404,6 +437,7 @@ function Upload({ setSelectedFolders(selectedFolderIds); }} onFirstPickName={prefillName} + onConnectionChange={setConnectionId} provider="share_point" token={token} initialSelectedFiles={selectedFiles} @@ -422,6 +456,7 @@ function Upload({ setSelectedFolders(selectedFolderIds); }} onFirstPickName={prefillName} + onConnectionChange={setConnectionId} provider="confluence" token={token} initialSelectedFiles={selectedFiles} @@ -466,6 +501,30 @@ function Upload({ : undefined; const selectedConnector = connectorFor(ingestor.type); const needsSetup = !!selectedConnector && !selectedConnector.available; + // S3 and Reddit keep their keys on a connection: once an account is + // chosen the key fields go away ("enter secrets once"). + const keyAccounts = + selectedConnector?.auth_kind === 'api_key' + ? connections.filter( + (c) => + c.connector_key === selectedConnector.key && + c.status === 'connected', + ) + : []; + const credentialKeys = new Set( + (selectedConnector?.credential_fields ?? []).map((f) => f.key), + ); + const usingSavedKeys = + keyAccounts.length > 0 && !!connectionId && connectionId !== 'new'; + + useEffect(() => { + if (selectedConnector?.auth_kind === 'api_key') { + setConnectionId(keyAccounts[0]?.id ?? 'new'); + } else { + setConnectionId(null); + } + // Reset only when the source type changes. + }, [ingestor.type]); const ingestorOptions: IngestorOption[] = IngestorFormSchemas.map( (schema) => ({ @@ -798,15 +857,20 @@ function Upload({ hasSharePointPicker || hasConfluencePicker ) { - const sessionToken = getSessionToken(ingestor.type as string); configData = { provider: ingestor.type as string, - session_token: sessionToken, + connection_id: connectionId, file_ids: selectedFiles, folder_ids: selectedFolders, }; } + if (usingSavedKeys) { + configData = Object.fromEntries( + Object.entries(configData).filter(([key]) => !credentialKeys.has(key)), + ); + configData.connection_id = connectionId; + } formData.append('data', JSON.stringify(configData)); const apiHost: string = envVar('VITE_API_HOST'); @@ -1028,6 +1092,7 @@ function Upload({ if (!ingestorSchemaForFields) return false; const formFields: FormField[] = ingestorSchemaForFields.fields; for (const field of formFields) { + if (usingSavedKeys && credentialKeys.has(field.name)) continue; if (field.required) { // Validate only required fields const value = diff --git a/frontend/src/utils/providerUtils.test.ts b/frontend/src/utils/providerUtils.test.ts new file mode 100644 index 00000000..b13ddabf --- /dev/null +++ b/frontend/src/utils/providerUtils.test.ts @@ -0,0 +1,39 @@ +const claim = vi.fn(); +vi.mock('../api/services/connectorsService', () => ({ + default: { claim: (...args: unknown[]) => claim(...args) }, +})); + +import { claimLegacySessionTokens } from './providerUtils'; + +describe('claimLegacySessionTokens', () => { + beforeEach(() => { + claim.mockReset(); + localStorage.clear(); + }); + + it('claims every stored session token once and removes it', async () => { + localStorage.setItem('google_drive_session_token', 'g-token'); + localStorage.setItem('confluence_session_token', 'c-token'); + claim.mockResolvedValue({ success: true, connection_id: 'x' }); + + await claimLegacySessionTokens('jwt'); + + expect(claim).toHaveBeenCalledWith('google_drive', 'g-token', 'jwt'); + expect(claim).toHaveBeenCalledWith('confluence', 'c-token', 'jwt'); + expect(claim).toHaveBeenCalledTimes(2); + expect(localStorage.getItem('google_drive_session_token')).toBeNull(); + expect(localStorage.getItem('confluence_session_token')).toBeNull(); + }); + + it('drops the token even when the claim fails', async () => { + localStorage.setItem('share_point_session_token', 's-token'); + claim.mockRejectedValue(new Error('offline')); + await claimLegacySessionTokens(null); + expect(localStorage.getItem('share_point_session_token')).toBeNull(); + }); + + it('does nothing without stored tokens', async () => { + await claimLegacySessionTokens(null); + expect(claim).not.toHaveBeenCalled(); + }); +}); diff --git a/frontend/src/utils/providerUtils.ts b/frontend/src/utils/providerUtils.ts index 837df1c7..da4625a5 100644 --- a/frontend/src/utils/providerUtils.ts +++ b/frontend/src/utils/providerUtils.ts @@ -1,25 +1,34 @@ /** - * Utility functions for managing session tokens for different cloud service providers. - * Follows the convention: {provider}_session_token + * Connector sign-ins used to keep a random session handle per provider in + * localStorage (``_session_token``). Credentials now live only on + * the server and the browser refers to a connection by its id, so the old + * handles are claimed once (linked to the signed-in user's connection) and + * removed. */ -import userService from '../api/services/userService'; +import connectorsService from '../api/services/connectorsService'; -export const getSessionToken = (provider: string): string | null => { - return localStorage.getItem(`${provider}_session_token`); -}; +const LEGACY_PROVIDERS = ['google_drive', 'share_point', 'confluence']; -export const setSessionToken = (provider: string, token: string): void => { - localStorage.setItem(`${provider}_session_token`, token); -}; +const legacyKey = (provider: string) => `${provider}_session_token`; -export const removeSessionToken = (provider: string): void => { - localStorage.removeItem(`${provider}_session_token`); -}; - -export const validateProviderSession = async ( +export const claimLegacySessionTokens = async ( token: string | null, - provider: string, -) => { - return await userService.validateConnectorSession(provider, token); +): Promise => { + for (const provider of LEGACY_PROVIDERS) { + let value: string | null = null; + try { + value = localStorage.getItem(legacyKey(provider)); + } catch { + return; + } + if (!value) continue; + try { + await connectorsService.claim(provider, value, token); + } catch { + // The handle is useless to this frontend either way; drop it. + } finally { + localStorage.removeItem(legacyKey(provider)); + } + } };