Connect services from one wizard, with no tokens in the browser

Every entry point (the Connectors page and drawer, Add Source, Add
Tool, the chat) opens one connect wizard: sign in or paste the
credentials, choose what to sync (content services only, skippable),
then a summary with Try it in chat. Tool services create their tools on
sign-in with write actions needing approval, and each action's
permission (Always allow, Needs approval, Off) can be changed in the
wizard or the drawer. The drawer also reconnects, syncs more content,
refreshes an MCP server's tools and removes a connection, keeping or
deleting what it feeds.

The file pickers work from a connection id and fetch a short-lived
picker token when they need one; no session token is kept in
localStorage any more, and any left by an older build is claimed once
and removed. S3 and Reddit reuse the keys stored on a connection.

A tool whose account needs signing in pauses on a Connect card in the
chat; after connecting, Continue resumes the call. A connection whose
sign-in stops working raises a Reconnect toast. The Connectors page is
hidden when the backend does not report connectors_enabled.
This commit is contained in:
arc53-machine committed 2026-09-28 17:21:44 +01:00
1 parent 3511004f50
commit 6e2ee43944
43 files changed
+2989 -555

No files matched your search

+1 -1
View File
@@ -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.
+20 -1
View File
@@ -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(
+3 -2
View File
@@ -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
+2
View File
@@ -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()}
>
<TeamNotificationToast />
<ConnectionHealthToast />
<ToolApprovalToast />
<UploadToast />
<ActionToast />
+15 -2
View File
@@ -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,
@@ -11,6 +11,21 @@ const json = async (response: Response) => {
}
};
export type ConnectionSetupBody = {
create_tools?: boolean;
tool_permissions?: Record<string, 'always' | 'ask' | 'off'>;
sync?: {
items: Record<string, unknown>;
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<string, string>;
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<string, string> },
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<string, 'always' | 'ask' | 'off'>,
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;
+7 -39
View File
@@ -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<any> =>
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<any> =>
apiClient.post(endpoints.USER.SYNC_CONNECTOR, { source_id: docId }, token),
getConnectorAuthUrl: (
provider: string,
token: string | null,
): Promise<any> => {
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<any> =>
apiClient.get(endpoints.USER.CONNECTOR_AUTH(provider), token),
connectionId?: string,
): Promise<any> =>
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<any> =>
apiClient.post(
endpoints.USER.CONNECTOR_VALIDATE_SESSION,
{
provider,
session_token: getSessionToken(provider),
},
token,
),
disconnectConnector: (
provider: string,
sessionToken: string,
token: string | null,
): Promise<any> =>
apiClient.post(
endpoints.USER.CONNECTOR_DISCONNECT,
{ provider, session_token: sessionToken },
token,
),
textToSpeech: (
text: string,
token: string | null,
+6 -2
View File
@@ -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<ConnectorAuthProps> = ({
provider,
connectionId,
onSuccess,
onError,
label,
@@ -76,7 +79,7 @@ const ConnectorAuth: React.FC<ConnectorAuthProps> = ({
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<ConnectorAuthProps> = ({
const authResponse = await userService.getConnectorAuthUrl(
provider,
token,
connectionId,
);
if (!mountedRef.current) {
authWindow.close();
+1 -1
View File
@@ -49,7 +49,7 @@ const ConnectorTree: React.FC<ConnectorTreeProps> = ({
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) {
+103 -32
View File
@@ -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> = {}): 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(
<FilePicker
provider="google_drive"
token={null}
onSelectionChange={() => undefined}
/>,
<Provider store={store}>
<FilePicker
provider={provider}
token={null}
onSelectionChange={() => undefined}
/>
</Provider>,
);
});
};
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(
<FilePicker
provider="share_point"
token={null}
onSelectionChange={() => 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();
});
});
+139 -119
View File
@@ -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<CloudFilePickerProps> = ({
onSelectionChange,
onDisconnect,
onFirstPickName,
connectionId: controlledConnectionId,
onConnectionChange,
provider,
token,
initialSelectedFiles = [],
}) => {
const { t } = useTranslation();
const dispatch = useDispatch<AppDispatch>();
const PROVIDER_CONFIG = {
google_drive: {
displayName: 'Drive',
@@ -116,6 +136,23 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
);
};
const allConnections = useSelector(selectConnections);
const accounts = allConnections.filter(
(connection) =>
connection.connector_key === provider &&
connection.status === 'connected',
);
const [chosenConnectionId, setChosenConnectionId] = useState<string | null>(
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<CloudFile[]>([]);
const [selectedFiles, setSelectedFiles] =
useState<string[]>(initialSelectedFiles);
@@ -134,14 +171,18 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
]);
const [searchQuery, setSearchQuery] = useState<string>('');
const [authError, setAuthError] = useState<string>('');
const [isConnected, setIsConnected] = useState(false);
const [userEmail, setUserEmail] = useState<string>('');
const [allowsSharedContent, setAllowsSharedContent] = useState(false);
const [activeTab, setActiveTab] = useState<'my_files' | 'shared'>('my_files');
const isConnected = !!activeConnectionId;
const scrollContainerRef = useRef<HTMLDivElement>(null);
const abortControllerRef = useRef<AbortController | null>(null);
useEffect(() => {
onConnectionChange?.(activeConnectionId);
}, [activeConnectionId]);
const isFolder = (file: CloudFile) => {
return (
file.isFolder ||
@@ -152,7 +193,7 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
const loadCloudFiles = useCallback(
async (
sessionToken: string,
connectionId: string,
folderId: string | null,
pageToken?: string,
searchQuery = '',
@@ -172,7 +213,7 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
try {
const body: Record<string, unknown> = {
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<CloudFilePickerProps> = ({
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<CloudFilePickerProps> = ({
[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<CloudFilePickerProps> = ({
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<CloudFilePickerProps> = ({
nextPageToken,
currentFolderId,
searchQuery,
provider,
activeConnectionId,
loadCloudFiles,
activeTab,
]);
@@ -325,10 +330,9 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
}, []);
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<CloudFilePickerProps> = ({
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<CloudFilePickerProps> = ({
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<CloudFilePickerProps> = ({
: 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<CloudFilePickerProps> = ({
}
};
const switchAccount = (connectionId: string) => {
setChosenConnectionId(connectionId);
setSelectedFiles([]);
setSelectedFolders([]);
onSelectionChange([], []);
};
return (
<div className="">
{authError && (
@@ -439,45 +447,57 @@ export const FilePicker: React.FC<CloudFilePickerProps> = ({
</Alert>
)}
{accounts.length > 1 && !controlledConnectionId && (
<FormField label={t('filePicker.account')} className="mb-4">
<Select
value={activeConnectionId ?? undefined}
onValueChange={switchAccount}
>
<SelectTrigger size="field" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{accounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
{account.account_label}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
)}
<ConnectorAuth
provider={provider}
label={t('filePicker.connectTo', {
provider: getProviderConfig(provider).displayName,
})}
onSuccess={(data) => {
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([]);
+58 -76
View File
@@ -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<GoogleDrivePickerProps> = ({
token,
connectionId: controlledConnectionId,
onConnectionChange,
onSelectionChange,
onFirstPickName,
}) => {
const { t } = useTranslation();
const dispatch = useDispatch<AppDispatch>();
const connections = useSelector(selectConnections);
const [chosenConnectionId, setChosenConnectionId] = useState<string | null>(
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<PickerFile[]>([]);
const [selectedFolders, setSelectedFolders] = useState<PickerFile[]>([]);
const [isLoading, setIsLoading] = useState(false);
const [userEmail, setUserEmail] = useState<string>('');
const [isConnected, setIsConnected] = useState(false);
const [authError, setAuthError] = useState<string>('');
const [accessToken, setAccessToken] = useState<string | null>(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<string | null> => {
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<GoogleDrivePickerProps> = ({
};
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<GoogleDrivePickerProps> = ({
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}
/>
@@ -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<string, unknown>) =>
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;
}) => (
<button
type="button"
onClick={() =>
onSuccess({ connection_id: 'conn-drive', user_email: 'a@example.com' })
}
>
{label}
</button>
),
}));
vi.mock('../components/FilePicker', () => ({
FilePicker: ({
onSelectionChange,
onFirstPickName,
}: {
onSelectionChange: (files: string[], folders?: string[]) => void;
onFirstPickName?: (name: string) => void;
}) => (
<button
type="button"
onClick={() => {
onFirstPickName?.('Handbook');
onSelectionChange([], ['folder-1']);
}}
>
pick-folder
</button>
),
}));
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(
<Provider store={store}>
<MemoryRouter initialEntries={['/settings/connectors']}>
<Routes>
<Route
path="/settings/connectors"
element={
<ConnectWizard connector={connector} onClose={onClose} />
}
/>
<Route path="/c/new" element={<div>NEW_CHAT</div>} />
</Routes>
</MemoryRouter>
</Provider>,
);
});
};
const click = async (text: string) => {
const button = Array.from(
document.body.querySelectorAll<HTMLButtonElement>('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<HTMLInputElement>(
'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<HTMLInputElement>('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<HTMLInputElement>('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');
});
});
+484
View File
@@ -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<AppDispatch>();
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<string | null>(
initialConnectionId,
);
const [credentials, setCredentials] = useState<Record<string, string>>({});
const [setupValues, setSetupValues] = useState<Record<string, string>>({});
const [pending, setPending] = useState(false);
const [error, setError] = useState('');
const [tools, setTools] = useState<ConnectionTool[]>([]);
const [sources, setSources] = useState<CreatedSource[]>([]);
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
const [selectedFolders, setSelectedFolders] = useState<string[]>([]);
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<string, unknown> =>
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 = () => (
<div className="flex flex-col gap-5">
<div className="flex items-center gap-4">
<span className="bg-muted flex size-12 shrink-0 items-center justify-center rounded-xl">
<ConnectorIcon icon={connector.icon} className="size-7" />
</span>
<p className="text-muted-foreground text-sm">
{connectorDescription(t, connector)}
</p>
</div>
{error && (
<Alert variant="destructive">
<CircleAlert />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{connector.auth_kind === 'oauth' ? (
<ConnectorAuth
provider={connector.key}
connectionId={
mode === 'reconnect' ? (connectionId ?? undefined) : undefined
}
label={t('settings.connectors.wizard.signIn', {
name,
interpolation: { escapeValue: false },
})}
onSuccess={(data) => afterSignIn(data.connection_id)}
onError={setError}
/>
) : (
<CredentialForm
connectorKey={connector.key}
idPrefix={`connect-${connector.key}`}
fields={connector.credential_fields}
values={credentials}
onChange={setCredentials}
/>
)}
</div>
);
const renderSetup = () => (
<div className="flex flex-col gap-5">
{error && (
<Alert variant="destructive">
<CircleAlert />
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{connector.key === 'google_drive' && envVar('VITE_GOOGLE_CLIENT_ID') ? (
<GoogleDrivePicker
token={token}
connectionId={connectionId}
onFirstPickName={prefillName}
onSelectionChange={(fileIds, folderIds = []) => {
setSelectedFiles(fileIds);
setSelectedFolders(folderIds);
}}
/>
) : PICKER_CONNECTORS.has(connector.key) ? (
<FilePicker
provider={connector.key}
token={token}
connectionId={connectionId}
onFirstPickName={prefillName}
onSelectionChange={(fileIds, folderIds = []) => {
setSelectedFiles(fileIds);
setSelectedFolders(folderIds);
}}
/>
) : (
<CredentialForm
connectorKey={connector.key}
idPrefix={`sync-${connector.key}`}
fields={connector.setup_fields}
values={setupValues}
onChange={(values) => {
setSetupValues(values);
const first = Object.values(values).find(Boolean);
if (first) prefillName(first);
}}
/>
)}
<div className="grid grid-cols-1 gap-x-4 gap-y-5 sm:grid-cols-2">
<Input
label={t('modals.uploadDoc.name')}
value={sourceName}
onChange={(e) => {
setNameTouched(true);
setSourceName(e.target.value);
}}
/>
<FormField label={t('settings.connectors.wizard.syncFrequency')}>
<Select value={frequency} onValueChange={setFrequency}>
<SelectTrigger size="field" className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
{FREQUENCIES.map((value) => (
<SelectItem key={value} value={value}>
{t(`settings.sources.syncFrequency.${value}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</FormField>
</div>
</div>
);
const renderDone = () => (
<div className="flex flex-col gap-5">
<p className="text-muted-foreground text-sm">{summary}</p>
{toolCount > 0 && connectionId && (
<div className="border-border overflow-hidden rounded-xl border">
<Accordion type="single" collapsible>
<AccordionItem value="tools">
<AccordionTrigger>
{t('settings.connectors.wizard.toolsHeading', {
count: toolCount,
})}
</AccordionTrigger>
<AccordionContent>
<div className="flex flex-col gap-3 px-4 pb-4">
{tools.map((tool) => (
<ToolPermissions
key={tool.id}
connectionId={connectionId}
tool={tool}
/>
))}
</div>
</AccordionContent>
</AccordionItem>
</Accordion>
</div>
)}
</div>
);
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 : (
<ModalActions
cancelLabel={t('cancel')}
onCancel={onClose}
submitLabel={
mode === 'reconnect'
? t('settings.connectors.status.reconnect')
: t('settings.connectors.status.connect')
}
onSubmit={submitCredentials}
pending={pending}
disabled={
!credentialsComplete(connector.credential_fields, credentials)
}
/>
)
) : step === 'setup' ? (
<ModalActions
cancelLabel={t('settings.connectors.wizard.skip')}
onCancel={() => (mode === 'sync' ? onClose() : setStep('done'))}
submitLabel={t('modals.uploadDoc.train')}
onSubmit={addSource}
pending={pending}
disabled={!canAddSource}
/>
) : (
<ModalActions
cancelLabel={t('settings.connectors.wizard.done')}
onCancel={finish}
submitLabel={t('settings.connectors.wizard.tryInChat')}
onSubmit={tryInChat}
/>
);
return (
<Modal
open
onOpenChange={(open) => !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()}
</Modal>
);
}
+324 -110
View File
@@ -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 (
<Card padding="sm" className="gap-4">
<div className="flex items-center justify-between gap-3">
<span className="text-foreground min-w-0 truncate text-sm font-medium">
{tool.display_name}
</span>
<Badge variant={tool.status ? 'success' : 'neutral'}>
{tool.status
? t('settings.connectors.detail.toolOn')
: t('settings.connectors.detail.toolOff')}
</Badge>
<Modal
open
onOpenChange={(open) => !open && onClose()}
title={t('settings.connectors.remove.title', {
name,
interpolation: { escapeValue: false },
})}
description={t('settings.connectors.remove.description')}
footer={
<ModalActions
cancelLabel={t('cancel')}
onCancel={onClose}
submitLabel={t('settings.connectors.detail.remove')}
onSubmit={remove}
pending={pending}
destructive
/>
}
>
<div className="flex flex-col gap-6">
{failed && (
<Alert variant="destructive">
<CircleAlert />
<AlertDescription>
{t('settings.connectors.remove.failed')}
</AlertDescription>
</Alert>
)}
{detail.sources.length > 0 && (
<FormField
float={false}
label={t('settings.connectors.remove.sourcesLabel', {
count: detail.sources.length,
})}
>
<ToggleGroup
type="single"
value={sources}
onValueChange={(value) =>
value && setSources(value as 'keep' | 'delete')
}
>
<ToggleGroupItem value="keep">
{t('settings.connectors.remove.keepSources')}
</ToggleGroupItem>
<ToggleGroupItem value="delete">
{t('settings.connectors.remove.deleteSources')}
</ToggleGroupItem>
</ToggleGroup>
</FormField>
)}
{detail.tools.length > 0 && (
<FormField
float={false}
label={t('settings.connectors.remove.toolsLabel', {
count: detail.tools.length,
})}
>
<ToggleGroup
type="single"
value={tools}
onValueChange={(value) =>
value && setTools(value as 'keep' | 'delete')
}
>
<ToggleGroupItem value="delete">
{t('settings.connectors.remove.deleteTools')}
</ToggleGroupItem>
<ToggleGroupItem value="keep">
{t('settings.connectors.remove.keepTools')}
</ToggleGroupItem>
</ToggleGroup>
</FormField>
)}
</div>
{groups.map((group) => (
<div key={group.access} className="flex flex-col gap-2">
<SectionHeader
as="h4"
size="sm"
title={t(`settings.connectors.capability.${group.access}`)}
/>
<ul className="flex flex-col gap-2">
{group.actions.map((action) => (
<li
key={action.name}
className="flex items-center justify-between gap-3"
>
<span
className="text-foreground min-w-0 truncate font-mono text-xs"
title={action.description || action.name}
>
{action.name}
</span>
<Badge variant={PERMISSION_VARIANT[action.permission]}>
{t(`settings.connectors.permission.${action.permission}`)}
</Badge>
</li>
))}
</ul>
</div>
))}
</Card>
</Modal>
);
}
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<void>;
}) {
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 (
<div className="flex flex-col gap-6">
<Card padding="none">
<ListRows>
<ListRow
title={t('settings.connectors.detail.connectedAs', {
account: detail.account_label,
interpolation: { escapeValue: false },
})}
title={accountTitle(detail)}
description={
detail.last_error ? (
<span title={detail.last_error}>{detail.last_error}</span>
@@ -137,66 +238,77 @@ function AccountSection({
type="button"
size="xs"
variant="outline"
onClick={onReconnect}
onClick={() => onReconnect(detail)}
>
<RefreshCw />
{t('settings.connectors.status.reconnect')}
</Button>
)}
{detail.status !== 'disconnected' && (
<ActionMenu
triggerLabel={t('settings.connectors.detail.accountMenu')}
options={[
{
icon: Unplug,
label: t('settings.connectors.detail.disconnect'),
onClick: () => onDisconnect(detail),
variant: 'destructive',
},
]}
/>
)}
<ActionMenu
triggerLabel={t('settings.connectors.detail.accountMenu')}
options={menu}
/>
</div>
}
/>
</ListRows>
</Card>
{detail.sources.length > 0 && (
{canSync && (
<section className="flex flex-col gap-2">
<SectionHeader
as="h3"
size="xs"
title={t('settings.connectors.detail.sources')}
actions={
detail.status === 'connected' ? (
<Button
type="button"
variant="link"
size="sm"
className="-mr-3"
onClick={() => onSyncMore(detail)}
>
<Plus />
{t('settings.connectors.detail.syncMore')}
</Button>
) : undefined
}
/>
<Card padding="none">
<ListRows>
{detail.sources.map((source) => (
<ListRow
key={source.id}
title={source.name}
description={
source.last_sync
? t('settings.connectors.detail.lastSync', {
date: formatDateTime(source.last_sync),
frequency: t(
`settings.sources.syncFrequency.${source.sync_frequency}`,
{ defaultValue: source.sync_frequency },
),
interpolation: { escapeValue: false },
})
: undefined
}
trailing={
source.sync_state === 'paused_reconnect' ? (
<Badge variant="warning">
{t('settings.connectors.detail.paused')}
</Badge>
) : undefined
}
/>
))}
</ListRows>
</Card>
{detail.sources.length === 0 ? (
<p className="text-muted-foreground text-sm">
{t('settings.connectors.detail.noSources')}
</p>
) : (
<Card padding="none">
<ListRows>
{detail.sources.map((source) => (
<ListRow
key={source.id}
title={source.name}
description={
source.last_sync
? t('settings.connectors.detail.lastSync', {
date: formatDateTime(source.last_sync),
frequency: t(
`settings.sources.syncFrequency.${source.sync_frequency}`,
{ defaultValue: source.sync_frequency },
),
interpolation: { escapeValue: false },
})
: undefined
}
trailing={
source.sync_state === 'paused_reconnect' ? (
<Badge variant="warning">
{t('settings.connectors.detail.paused')}
</Badge>
) : undefined
}
/>
))}
</ListRows>
</Card>
)}
</section>
)}
{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' ? (
<Button
type="button"
variant="link"
size="sm"
className="-mr-3"
loading={refreshing}
onClick={() => {
setRefreshing(true);
onRefreshTools(detail).finally(() => setRefreshing(false));
}}
>
<RotateCw />
{t('settings.connectors.detail.refreshTools')}
</Button>
) : undefined
}
/>
{detail.tools.map((tool) => (
<ToolActions key={tool.id} tool={tool} />
<div key={tool.id} className="flex flex-col gap-2">
<div className="flex items-center justify-between gap-3">
<span className="text-foreground min-w-0 truncate text-sm font-medium">
{tool.display_name}
</span>
<Badge variant={tool.status ? 'success' : 'neutral'}>
{tool.status
? t('settings.connectors.detail.toolOn')
: t('settings.connectors.detail.toolOff')}
</Badge>
</div>
<ToolPermissions connectionId={detail.id} tool={tool} />
</div>
))}
</section>
)}
@@ -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<AppDispatch>();
@@ -239,15 +382,18 @@ export default function ConnectionDrawer({
const [toDisconnect, setToDisconnect] = useState<ConnectionDetail | null>(
null,
);
const [toRemove, setToRemove] = useState<ConnectionDetail | null>(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({
</p>
<CapabilityBadges capabilities={connector.capabilities} />
{connector.publisher === 'custom' && (
<Alert variant="warning" role="note">
<CircleAlert />
<AlertDescription>
{t('settings.connectors.unverified')}
</AlertDescription>
</Alert>
)}
{connector.needs_setup && (
<Alert variant="warning">
<CircleAlert />
@@ -368,7 +562,7 @@ export default function ConnectionDrawer({
) : undefined
}
/>
{loading ? (
{loading && details.length === 0 ? (
<LoadingState fill="block" />
) : failed ? (
<EmptyState
@@ -408,9 +602,18 @@ export default function ConnectionDrawer({
{details.map((detail) => (
<AccountSection
key={detail.id}
connector={connector}
detail={detail}
onReconnect={() => onConnect(connector)}
onReconnect={reconnect}
onDisconnect={setToDisconnect}
onRemove={setToRemove}
onSyncMore={(d) =>
onConnect(connector, {
mode: 'sync',
connectionId: d.id,
})
}
onRefreshTools={refreshTools}
/>
))}
</div>
@@ -433,6 +636,17 @@ export default function ConnectionDrawer({
submitLabel={t('settings.connectors.detail.disconnect')}
variant="destructive"
/>
{toRemove && (
<RemoveConnectionModal
detail={toRemove}
name={name}
onClose={() => setToRemove(null)}
onRemoved={() => {
setToRemove(null);
refresh();
}}
/>
)}
</>
);
}
@@ -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.<connector>_<key>`, then
* `settings.connectors.fields.<key>`, 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<string, string>;
onChange: (values: Record<string, string>) => void;
idPrefix: string;
labelSurface?: 'card' | 'background' | 'muted';
}) {
const { t } = useTranslation();
return (
<div className="flex flex-col gap-5">
{fields.map((field) => (
<Input
key={field.key}
id={`${idPrefix}-${field.key}`}
label={t(`settings.connectors.fields.${connectorKey}_${field.key}`, {
defaultValue: t(`settings.connectors.fields.${field.key}`, {
defaultValue: field.label,
}),
})}
labelSurface={labelSurface}
type={field.secret ? 'password' : 'text'}
autoComplete={field.secret ? 'new-password' : 'off'}
required={field.required}
value={values[field.key] ?? ''}
onChange={(e) => onChange({ ...values, [field.key]: e.target.value })}
/>
))}
</div>
);
}
/** Whether every required field has a value. */
export const credentialsComplete = (
fields: CredentialField[],
values: Record<string, string>,
) => fields.every((field) => !field.required || !!values[field.key]?.trim());
+118
View File
@@ -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<ConnectionToolAction[]>(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 (
<Card padding="sm" className="gap-4">
{groups.map((group) => (
<div key={group.access} className="flex flex-col gap-2">
<SectionHeader
as="h4"
size="sm"
title={t(`settings.connectors.capability.${group.access}`)}
/>
<ul className="flex flex-col gap-2">
{group.actions.map((action) => (
<li
key={action.name}
className="flex items-center justify-between gap-3"
>
<span
className="text-foreground min-w-0 truncate font-mono text-xs"
title={action.description || action.name}
>
{action.name}
</span>
<Select
value={action.permission}
disabled={readOnly}
onValueChange={(value) =>
setPermission(action.name, value as ActionPermission)
}
>
<SelectTrigger
size="sm"
className="w-40 shrink-0"
aria-label={t('settings.connectors.permission.label', {
action: action.name,
})}
>
<SelectValue />
</SelectTrigger>
<SelectContent>
{PERMISSIONS.map((permission) => (
<SelectItem key={permission} value={permission}>
{t(`settings.connectors.permission.${permission}`)}
</SelectItem>
))}
</SelectContent>
</Select>
</li>
))}
</ul>
</div>
))}
</Card>
);
}
+18 -2
View File
@@ -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<boolean>) {
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) =>
@@ -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<string, unknown> }
export type LaunchOptions = {
/** `connect` a new account, `reconnect` one, or `sync` more from one. */
mode?: Exclude<WizardMode, 'done'>;
connectionId?: string;
/** An existing MCP tool to reconnect through the MCP server form. */
mcpServer?: Record<string, unknown>;
};
type Active =
| {
kind: 'wizard';
connector: ConnectorDefinition;
mode: WizardMode;
connectionId?: string;
}
| {
kind: 'mcp';
connector: ConnectorDefinition;
server?: Record<string, unknown>;
}
| 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<AppDispatch>();
const navigate = useNavigate();
const token = useSelector(selectToken);
const [active, setActive] = useState<Launch>(null);
const [active, setActive] = useState<Active>(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' && (
<Upload
receivedFile={[]}
setModalState={close}
isOnboarding={false}
renderTab={null}
close={() => setActive(null)}
initialIngestor={active.ingestor}
onSuccessfulUpload={finish}
selectUploadedDoc={false}
/>
)}
{active?.kind === 'tool' && (
<ConfigToolModal
modalState="ACTIVE"
setModalState={close}
tool={active.tool}
getUserTools={finish}
{active?.kind === 'wizard' && (
<ConnectWizard
connector={active.connector}
mode={active.mode}
connectionId={active.connectionId}
onClose={() => {
setActive(null);
refresh();
}}
/>
)}
{active?.kind === 'mcp' && (
<MCPServerModal
modalState="ACTIVE"
setModalState={close}
setModalState={closeMcp}
server={active.server}
onServerSaved={finish}
onServerSaved={afterMcpSave}
/>
)}
</>
@@ -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(
<Provider store={store}>
<MemoryRouter initialEntries={['/c/1']}>
<Routes>
<Route
path="/c/1"
element={
<ConnectToolCallBar
toolCall={toolCall}
onToolAction={onToolAction}
/>
}
/>
<Route path="/settings/connectors" element={<div>DRAWER</div>} />
</Routes>
</MemoryRouter>
</Provider>,
);
});
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');
});
});
@@ -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<AppDispatch>();
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 (
<div className="border-border bg-muted mb-2 flex w-full flex-wrap items-center gap-3 overflow-hidden rounded-2xl border px-4 py-2.5">
<div className="flex min-w-0 flex-1 items-center gap-2">
{connector ? (
<ConnectorIcon icon={connector.icon} className="size-5 shrink-0" />
) : (
<Plug className="text-muted-foreground size-5 shrink-0" aria-hidden />
)}
<span className="text-sm">
{t('conversation.toolApproval.connectTitle', {
name,
interpolation: { escapeValue: false },
})}
</span>
</div>
<div className="flex items-center gap-2">
{connected ? (
<Button
type="button"
size="xs"
shape="pill"
onClick={() => onToolAction?.(toolCall.call_id, 'approved')}
>
{t('conversation.toolApproval.continue')}
</Button>
) : (
<Button type="button" size="xs" shape="pill" onClick={connect}>
{t('conversation.toolApproval.connect', {
name,
interpolation: { escapeValue: false },
})}
</Button>
)}
<Button
type="button"
variant="outline"
size="xs"
shape="pill"
onClick={() => onToolAction?.(toolCall.call_id, 'denied')}
>
{t('conversation.toolApproval.skip')}
</Button>
</div>
{modals}
</div>
);
}
@@ -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) => (
<div className="animate-in fade-in mt-4 mr-5 ml-6 duration-160 ease-out motion-reduce:animate-none">
<ToolCallApprovalBar
toolCall={toolCall}
onToolAction={onToolAction}
/>
{toolCall.connection_required ? (
<ConnectToolCallBar
toolCall={toolCall}
onToolAction={onToolAction}
/>
) : (
<ToolCallApprovalBar
toolCall={toolCall}
onToolAction={onToolAction}
/>
)}
</div>
)}
renderWikiWrite={(toolCall: ToolCallsType, isLive: boolean) => (
+12
View File
@@ -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;
};
+3
View File
@@ -44,6 +44,9 @@ const KNOWN_TYPES: ReadonlySet<string> = 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',
]);
/**
+11
View File
@@ -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
+82 -5
View File
@@ -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",
+82 -5
View File
@@ -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",
+82 -5
View File
@@ -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",
+78 -5
View File
@@ -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": "新しいチャットを開く",
+90 -5
View File
@@ -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": "Открыть новый чат",
+78 -5
View File
@@ -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": "開啟新聊天",
+78 -5
View File
@@ -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": "打开新聊天",
+26 -2
View File
@@ -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<AppDispatch>();
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}
<PairDeviceModal
modalState={pairModalState}
setModalState={setPairModalState}
+6 -1
View File
@@ -6,6 +6,7 @@ import { Link } from 'react-router-dom';
import { ListRow, ListRows } from '@/components/ui/list-row';
import { selectIsAdmin } from '@/preferences/preferenceSlice';
import { selectConnectorsEnabled } from '../connectors/connectorsSlice';
import { getVisibleGroups, type Section } from './sections';
/**
@@ -17,7 +18,11 @@ import { getVisibleGroups, type Section } from './sections';
export default function SectionIndexPage({ section }: { section: Section }) {
const { t } = useTranslation();
const isAdmin = useSelector(selectIsAdmin);
const groups = getVisibleGroups(section, { isAdmin });
const connectorsEnabled = useSelector(selectConnectorsEnabled);
const groups = getVisibleGroups(section, {
isAdmin,
features: { connectors: connectorsEnabled },
});
return (
<div className="flex flex-col gap-8">
+7 -1
View File
@@ -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) => {
+6 -3
View File
@@ -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 (
+15 -2
View File
@@ -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<Record<NonNullable<SectionItem['feature']>, 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);
}
@@ -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<AppDispatch>();
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<string>();
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<string, unknown>;
const name = String(payload.name ?? '');
const key = String(payload.connector_key ?? '');
return (
<Toast key={event.id}>
<ToastHeader variant="warning">
<ToastTitle wrap>
{t('settings.connectors.health.reconnect', {
name,
interpolation: { escapeValue: false },
})}
</ToastTitle>
<ToastActions>
<Button
type="button"
variant="ghost-muted"
size="icon-sm"
onClick={() => onDismiss(event.id as string)}
aria-label={t('notifications.dismiss')}
>
<X />
</Button>
</ToastActions>
</ToastHeader>
<ToastFooter>
<Button asChild size="sm" shape="pill">
<Link
to={`/settings/connectors?connector=${encodeURIComponent(key)}`}
onClick={() => onDismiss(event.id as string)}
>
{t('settings.connectors.status.reconnect')}
</Link>
</Button>
</ToastFooter>
</Toast>
);
})}
</>
);
}
+2 -2
View File
@@ -209,9 +209,9 @@ export default function Connectors() {
<ConnectionDrawer
connector={openConnector}
onClose={closeDrawer}
onConnect={(connector) => {
onConnect={(connector, options) => {
closeDrawer();
launch(connector);
launch(connector, options);
}}
/>
{modals}
+1 -30
View File
@@ -218,42 +218,13 @@ export default function Sources({
});
};
const getConnectorProvider = async (doc: Doc): Promise<string | null> => {
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);
+69 -4
View File
@@ -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<string[]>([]);
const [selectedFolders, setSelectedFolders] = useState<string[]>([]);
// The connection (account) the source syncs from. Pickers report it; S3
// and Reddit pick it here ('' means "enter new credentials").
const [connectionId, setConnectionId] = useState<string | null>(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 (
<div className="flex flex-col gap-5">
{keyAccounts.length > 0 && (
<UiFormField label={t('filePicker.account')}>
<Select
value={connectionId ?? 'new'}
onValueChange={(value) => setConnectionId(value)}
>
<SelectTrigger className="w-full" size="field" shape="pill">
<SelectValue />
</SelectTrigger>
<SelectContent>
{keyAccounts.map((account) => (
<SelectItem key={account.id} value={account.id}>
{t('settings.connectors.detail.keyEnding', {
hint: account.account_label,
interpolation: { escapeValue: false },
})}
</SelectItem>
))}
<SelectItem value="new">
{t('modals.uploadDoc.newCredentials')}
</SelectItem>
</SelectContent>
</Select>
</UiFormField>
)}
<div className="flex flex-col gap-5">
{generalFields.map((field: FormField) => renderField(field))}
</div>
@@ -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 =
+39
View File
@@ -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();
});
});
+26 -17
View File
@@ -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 (``<provider>_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<void> => {
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));
}
}
};