+ {keyAccounts.length > 0 && (
+
+
+
+ )}
{generalFields.map((field: FormField) => renderField(field))}
@@ -353,6 +383,7 @@ function Upload({
setSelectedFolders(selectedFolderIds);
}}
onFirstPickName={prefillName}
+ onConnectionChange={setConnectionId}
provider={ingestor.type as unknown as string}
token={token}
initialSelectedFiles={selectedFiles}
@@ -373,6 +404,7 @@ function Upload({
setSelectedFolders(selectedFolderIds);
}}
onFirstPickName={prefillName}
+ onConnectionChange={setConnectionId}
token={token}
/>
) : (
@@ -386,6 +418,7 @@ function Upload({
setSelectedFolders(selectedFolderIds);
}}
onFirstPickName={prefillName}
+ onConnectionChange={setConnectionId}
provider="google_drive"
token={token}
initialSelectedFiles={selectedFiles}
@@ -404,6 +437,7 @@ function Upload({
setSelectedFolders(selectedFolderIds);
}}
onFirstPickName={prefillName}
+ onConnectionChange={setConnectionId}
provider="share_point"
token={token}
initialSelectedFiles={selectedFiles}
@@ -422,6 +456,7 @@ function Upload({
setSelectedFolders(selectedFolderIds);
}}
onFirstPickName={prefillName}
+ onConnectionChange={setConnectionId}
provider="confluence"
token={token}
initialSelectedFiles={selectedFiles}
@@ -466,6 +501,30 @@ function Upload({
: undefined;
const selectedConnector = connectorFor(ingestor.type);
const needsSetup = !!selectedConnector && !selectedConnector.available;
+ // S3 and Reddit keep their keys on a connection: once an account is
+ // chosen the key fields go away ("enter secrets once").
+ const keyAccounts =
+ selectedConnector?.auth_kind === 'api_key'
+ ? connections.filter(
+ (c) =>
+ c.connector_key === selectedConnector.key &&
+ c.status === 'connected',
+ )
+ : [];
+ const credentialKeys = new Set(
+ (selectedConnector?.credential_fields ?? []).map((f) => f.key),
+ );
+ const usingSavedKeys =
+ keyAccounts.length > 0 && !!connectionId && connectionId !== 'new';
+
+ useEffect(() => {
+ if (selectedConnector?.auth_kind === 'api_key') {
+ setConnectionId(keyAccounts[0]?.id ?? 'new');
+ } else {
+ setConnectionId(null);
+ }
+ // Reset only when the source type changes.
+ }, [ingestor.type]);
const ingestorOptions: IngestorOption[] = IngestorFormSchemas.map(
(schema) => ({
@@ -798,15 +857,20 @@ function Upload({
hasSharePointPicker ||
hasConfluencePicker
) {
- const sessionToken = getSessionToken(ingestor.type as string);
configData = {
provider: ingestor.type as string,
- session_token: sessionToken,
+ connection_id: connectionId,
file_ids: selectedFiles,
folder_ids: selectedFolders,
};
}
+ if (usingSavedKeys) {
+ configData = Object.fromEntries(
+ Object.entries(configData).filter(([key]) => !credentialKeys.has(key)),
+ );
+ configData.connection_id = connectionId;
+ }
formData.append('data', JSON.stringify(configData));
const apiHost: string = envVar('VITE_API_HOST');
@@ -1028,6 +1092,7 @@ function Upload({
if (!ingestorSchemaForFields) return false;
const formFields: FormField[] = ingestorSchemaForFields.fields;
for (const field of formFields) {
+ if (usingSavedKeys && credentialKeys.has(field.name)) continue;
if (field.required) {
// Validate only required fields
const value =
diff --git a/frontend/src/utils/providerUtils.test.ts b/frontend/src/utils/providerUtils.test.ts
new file mode 100644
index 00000000..b13ddabf
--- /dev/null
+++ b/frontend/src/utils/providerUtils.test.ts
@@ -0,0 +1,39 @@
+const claim = vi.fn();
+vi.mock('../api/services/connectorsService', () => ({
+ default: { claim: (...args: unknown[]) => claim(...args) },
+}));
+
+import { claimLegacySessionTokens } from './providerUtils';
+
+describe('claimLegacySessionTokens', () => {
+ beforeEach(() => {
+ claim.mockReset();
+ localStorage.clear();
+ });
+
+ it('claims every stored session token once and removes it', async () => {
+ localStorage.setItem('google_drive_session_token', 'g-token');
+ localStorage.setItem('confluence_session_token', 'c-token');
+ claim.mockResolvedValue({ success: true, connection_id: 'x' });
+
+ await claimLegacySessionTokens('jwt');
+
+ expect(claim).toHaveBeenCalledWith('google_drive', 'g-token', 'jwt');
+ expect(claim).toHaveBeenCalledWith('confluence', 'c-token', 'jwt');
+ expect(claim).toHaveBeenCalledTimes(2);
+ expect(localStorage.getItem('google_drive_session_token')).toBeNull();
+ expect(localStorage.getItem('confluence_session_token')).toBeNull();
+ });
+
+ it('drops the token even when the claim fails', async () => {
+ localStorage.setItem('share_point_session_token', 's-token');
+ claim.mockRejectedValue(new Error('offline'));
+ await claimLegacySessionTokens(null);
+ expect(localStorage.getItem('share_point_session_token')).toBeNull();
+ });
+
+ it('does nothing without stored tokens', async () => {
+ await claimLegacySessionTokens(null);
+ expect(claim).not.toHaveBeenCalled();
+ });
+});
diff --git a/frontend/src/utils/providerUtils.ts b/frontend/src/utils/providerUtils.ts
index 837df1c7..da4625a5 100644
--- a/frontend/src/utils/providerUtils.ts
+++ b/frontend/src/utils/providerUtils.ts
@@ -1,25 +1,34 @@
/**
- * Utility functions for managing session tokens for different cloud service providers.
- * Follows the convention: {provider}_session_token
+ * Connector sign-ins used to keep a random session handle per provider in
+ * localStorage (``
_session_token``). Credentials now live only on
+ * the server and the browser refers to a connection by its id, so the old
+ * handles are claimed once (linked to the signed-in user's connection) and
+ * removed.
*/
-import userService from '../api/services/userService';
+import connectorsService from '../api/services/connectorsService';
-export const getSessionToken = (provider: string): string | null => {
- return localStorage.getItem(`${provider}_session_token`);
-};
+const LEGACY_PROVIDERS = ['google_drive', 'share_point', 'confluence'];
-export const setSessionToken = (provider: string, token: string): void => {
- localStorage.setItem(`${provider}_session_token`, token);
-};
+const legacyKey = (provider: string) => `${provider}_session_token`;
-export const removeSessionToken = (provider: string): void => {
- localStorage.removeItem(`${provider}_session_token`);
-};
-
-export const validateProviderSession = async (
+export const claimLegacySessionTokens = async (
token: string | null,
- provider: string,
-) => {
- return await userService.validateConnectorSession(provider, token);
+): Promise => {
+ for (const provider of LEGACY_PROVIDERS) {
+ let value: string | null = null;
+ try {
+ value = localStorage.getItem(legacyKey(provider));
+ } catch {
+ return;
+ }
+ if (!value) continue;
+ try {
+ await connectorsService.claim(provider, value, token);
+ } catch {
+ // The handle is useless to this frontend either way; drop it.
+ } finally {
+ localStorage.removeItem(legacyKey(provider));
+ }
+ }
};