feat: implement re-authentication flows for Google Drive, Gmail, and Calendar connectors

- Added re-authentication endpoints for Google Drive, Gmail, and Calendar connectors to handle expired authentication.
- Enhanced the UI to prompt users for re-authentication when their credentials are expired.
- Updated backend logic to mark connectors as 'auth_expired' and manage re-authentication requests effectively.
- Improved error handling for authentication failures across Google connectors.
This commit is contained in:
Anish Sarkar 2026-03-19 17:51:59 +05:30
parent 36f4709225
commit c9deae940c
9 changed files with 432 additions and 98 deletions

View file

@ -10,8 +10,10 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from google_auth_oauthlib.flow import Flow from google_auth_oauthlib.flow import Flow
from pydantic import ValidationError from pydantic import ValidationError
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import flag_modified
from app.config import config from app.config import config
from app.connectors.google_gmail_connector import fetch_google_user_email from app.connectors.google_gmail_connector import fetch_google_user_email
@ -111,6 +113,66 @@ async def connect_calendar(space_id: int, user: User = Depends(current_active_us
) from e ) from e
@router.get("/auth/google/calendar/connector/reauth")
async def reauth_calendar(
space_id: int,
connector_id: int,
return_url: str | None = None,
user: User = Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
"""Initiate Google Calendar re-authentication for an existing connector."""
try:
result = await session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user.id,
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR,
)
)
connector = result.scalars().first()
if not connector:
raise HTTPException(
status_code=404,
detail="Google Calendar connector not found or access denied",
)
if not config.SECRET_KEY:
raise HTTPException(
status_code=500, detail="SECRET_KEY not configured for OAuth security."
)
flow = get_google_flow()
state_manager = get_state_manager()
extra: dict = {"connector_id": connector_id}
if return_url and return_url.startswith("/"):
extra["return_url"] = return_url
state_encoded = state_manager.generate_secure_state(space_id, user.id, **extra)
auth_url, _ = flow.authorization_url(
access_type="offline",
prompt="consent",
include_granted_scopes="true",
state=state_encoded,
)
logger.info(
f"Initiating Google Calendar re-auth for user {user.id}, connector {connector_id}"
)
return {"auth_url": auth_url}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to initiate Calendar re-auth: {e!s}", exc_info=True)
raise HTTPException(
status_code=500, detail=f"Failed to initiate Calendar re-auth: {e!s}"
) from e
@router.get("/auth/google/calendar/connector/callback") @router.get("/auth/google/calendar/connector/callback")
async def calendar_callback( async def calendar_callback(
request: Request, request: Request,
@ -197,6 +259,42 @@ async def calendar_callback(
# Mark that credentials are encrypted for backward compatibility # Mark that credentials are encrypted for backward compatibility
creds_dict["_token_encrypted"] = True creds_dict["_token_encrypted"] = True
reauth_connector_id = data.get("connector_id")
reauth_return_url = data.get("return_url")
if reauth_connector_id:
result = await session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == reauth_connector_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.GOOGLE_CALENDAR_CONNECTOR,
)
)
db_connector = result.scalars().first()
if not db_connector:
raise HTTPException(
status_code=404,
detail="Connector not found or access denied during re-auth",
)
db_connector.config = {**creds_dict}
flag_modified(db_connector, "config")
await session.commit()
await session.refresh(db_connector)
logger.info(
f"Re-authenticated Calendar connector {db_connector.id} for user {user_id}"
)
if reauth_return_url and reauth_return_url.startswith("/"):
return RedirectResponse(
url=f"{config.NEXT_FRONTEND_URL}{reauth_return_url}"
)
return RedirectResponse(
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/connectors/callback?success=true&connector=google-calendar-connector&connectorId={db_connector.id}"
)
# Check for duplicate connector (same account already connected) # Check for duplicate connector (same account already connected)
is_duplicate = await check_duplicate_connector( is_duplicate = await check_duplicate_connector(
session, session,

View file

@ -525,6 +525,21 @@ async def list_google_drive_folders(
raise raise
except Exception as e: except Exception as e:
logger.error(f"Error listing Drive contents: {e!s}", exc_info=True) logger.error(f"Error listing Drive contents: {e!s}", exc_info=True)
error_lower = str(e).lower()
if "invalid_grant" in error_lower or "token has been expired or revoked" in error_lower or "authentication failed" in error_lower:
from sqlalchemy.orm.attributes import flag_modified
try:
if connector and not connector.config.get("auth_expired"):
connector.config = {**connector.config, "auth_expired": True}
flag_modified(connector, "config")
await session.commit()
logger.info(f"Marked connector {connector_id} as auth_expired")
except Exception:
logger.warning(f"Failed to persist auth_expired for connector {connector_id}", exc_info=True)
raise HTTPException(
status_code=400, detail="Google Drive authentication expired. Please re-authenticate."
) from e
raise HTTPException( raise HTTPException(
status_code=500, detail=f"Failed to list Drive contents: {e!s}" status_code=500, detail=f"Failed to list Drive contents: {e!s}"
) from e ) from e

View file

@ -10,8 +10,10 @@ from fastapi import APIRouter, Depends, HTTPException, Request
from fastapi.responses import RedirectResponse from fastapi.responses import RedirectResponse
from google_auth_oauthlib.flow import Flow from google_auth_oauthlib.flow import Flow
from pydantic import ValidationError from pydantic import ValidationError
from sqlalchemy import select
from sqlalchemy.exc import IntegrityError from sqlalchemy.exc import IntegrityError
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from sqlalchemy.orm.attributes import flag_modified
from app.config import config from app.config import config
from app.connectors.google_gmail_connector import fetch_google_user_email from app.connectors.google_gmail_connector import fetch_google_user_email
@ -129,6 +131,66 @@ async def connect_gmail(space_id: int, user: User = Depends(current_active_user)
) from e ) from e
@router.get("/auth/google/gmail/connector/reauth")
async def reauth_gmail(
space_id: int,
connector_id: int,
return_url: str | None = None,
user: User = Depends(current_active_user),
session: AsyncSession = Depends(get_async_session),
):
"""Initiate Gmail re-authentication for an existing connector."""
try:
result = await session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == connector_id,
SearchSourceConnector.user_id == user.id,
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR,
)
)
connector = result.scalars().first()
if not connector:
raise HTTPException(
status_code=404,
detail="Gmail connector not found or access denied",
)
if not config.SECRET_KEY:
raise HTTPException(
status_code=500, detail="SECRET_KEY not configured for OAuth security."
)
flow = get_google_flow()
state_manager = get_state_manager()
extra: dict = {"connector_id": connector_id}
if return_url and return_url.startswith("/"):
extra["return_url"] = return_url
state_encoded = state_manager.generate_secure_state(space_id, user.id, **extra)
auth_url, _ = flow.authorization_url(
access_type="offline",
prompt="consent",
include_granted_scopes="true",
state=state_encoded,
)
logger.info(
f"Initiating Gmail re-auth for user {user.id}, connector {connector_id}"
)
return {"auth_url": auth_url}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to initiate Gmail re-auth: {e!s}", exc_info=True)
raise HTTPException(
status_code=500, detail=f"Failed to initiate Gmail re-auth: {e!s}"
) from e
@router.get("/auth/google/gmail/connector/callback") @router.get("/auth/google/gmail/connector/callback")
async def gmail_callback( async def gmail_callback(
request: Request, request: Request,
@ -228,6 +290,42 @@ async def gmail_callback(
# Mark that credentials are encrypted for backward compatibility # Mark that credentials are encrypted for backward compatibility
creds_dict["_token_encrypted"] = True creds_dict["_token_encrypted"] = True
reauth_connector_id = data.get("connector_id")
reauth_return_url = data.get("return_url")
if reauth_connector_id:
result = await session.execute(
select(SearchSourceConnector).filter(
SearchSourceConnector.id == reauth_connector_id,
SearchSourceConnector.user_id == user_id,
SearchSourceConnector.search_space_id == space_id,
SearchSourceConnector.connector_type
== SearchSourceConnectorType.GOOGLE_GMAIL_CONNECTOR,
)
)
db_connector = result.scalars().first()
if not db_connector:
raise HTTPException(
status_code=404,
detail="Connector not found or access denied during re-auth",
)
db_connector.config = {**creds_dict}
flag_modified(db_connector, "config")
await session.commit()
await session.refresh(db_connector)
logger.info(
f"Re-authenticated Gmail connector {db_connector.id} for user {user_id}"
)
if reauth_return_url and reauth_return_url.startswith("/"):
return RedirectResponse(
url=f"{config.NEXT_FRONTEND_URL}{reauth_return_url}"
)
return RedirectResponse(
url=f"{config.NEXT_FRONTEND_URL}/dashboard/{space_id}/connectors/callback?success=true&connector=google-gmail-connector&connectorId={db_connector.id}"
)
# Check for duplicate connector (same account already connected) # Check for duplicate connector (same account already connected)
is_duplicate = await check_duplicate_connector( is_duplicate = await check_duplicate_connector(
session, session,

View file

@ -956,23 +956,46 @@ async def index_connector_content(
index_google_drive_files_task, index_google_drive_files_task,
) )
if not drive_items or not drive_items.has_items(): if drive_items and drive_items.has_items():
raise HTTPException( logger.info(
status_code=400, f"Triggering Google Drive indexing for connector {connector_id} into search space {search_space_id}, "
detail="Google Drive indexing requires drive_items body parameter with folders or files", f"folders: {len(drive_items.folders)}, files: {len(drive_items.files)}"
)
items_dict = drive_items.model_dump()
else:
# Quick Index / periodic sync: fall back to stored config
config = connector.config or {}
selected_folders = config.get("selected_folders", [])
selected_files = config.get("selected_files", [])
if not selected_folders and not selected_files:
raise HTTPException(
status_code=400,
detail="Google Drive indexing requires folders or files to be configured. "
"Please select folders/files to index.",
)
indexing_options = config.get(
"indexing_options",
{
"max_files_per_folder": 100,
"incremental_sync": True,
"include_subfolders": True,
},
)
items_dict = {
"folders": selected_folders,
"files": selected_files,
"indexing_options": indexing_options,
}
logger.info(
f"Triggering Google Drive indexing for connector {connector_id} into search space {search_space_id} "
f"using existing config"
) )
logger.info(
f"Triggering Google Drive indexing for connector {connector_id} into search space {search_space_id}, "
f"folders: {len(drive_items.folders)}, files: {len(drive_items.files)}"
)
# Pass structured data to Celery task
index_google_drive_files_task.delay( index_google_drive_files_task.delay(
connector_id, connector_id,
search_space_id, search_space_id,
str(user.id), str(user.id),
drive_items.model_dump(), # Convert to dict for JSON serialization items_dict,
) )
response_message = "Google Drive indexing started in the background." response_message = "Google Drive indexing started in the background."
@ -3233,6 +3256,12 @@ async def get_drive_picker_token(
raise raise
except Exception as e: except Exception as e:
logger.error(f"Failed to get Drive picker token: {e!s}", exc_info=True) logger.error(f"Failed to get Drive picker token: {e!s}", exc_info=True)
if _is_auth_error(str(e)):
await _persist_auth_expired(session, connector_id)
raise HTTPException(
status_code=400,
detail="Google Drive authentication expired. Please re-authenticate.",
) from e
raise HTTPException( raise HTTPException(
status_code=500, status_code=500,
detail="Failed to retrieve access token. Check server logs for details.", detail="Failed to retrieve access token. Check server logs for details.",

View file

@ -3,6 +3,7 @@
import { useAtomValue, useSetAtom } from "jotai"; import { useAtomValue, useSetAtom } from "jotai";
import { AlertTriangle, Cable, Settings } from "lucide-react"; import { AlertTriangle, Cable, Settings } from "lucide-react";
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react"; import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react";
import { createPortal } from "react-dom";
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms"; import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom"; import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
import { import {
@ -215,11 +216,8 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
return ( return (
<Dialog <Dialog
open={isOpen} open={isOpen}
onOpenChange={(open) => { modal={false}
if (!open && pickerOpen) return; onOpenChange={handleOpenChange}
handleOpenChange(open);
}}
modal={!pickerOpen}
> >
{showTrigger && ( {showTrigger && (
<TooltipIconButton <TooltipIconButton
@ -256,8 +254,26 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
</TooltipIconButton> </TooltipIconButton>
)} )}
{isOpen &&
createPortal(
<div
className="fixed inset-0 z-50 bg-black/50 backdrop-blur-sm"
aria-hidden="true"
onClick={() => {
if (!pickerOpen) handleOpenChange(false);
}}
/>,
document.body
)}
<DialogContent <DialogContent
onFocusOutside={(e) => e.preventDefault()} onFocusOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => {
if (pickerOpen) e.preventDefault();
}}
onPointerDownOutside={(e) => {
if (pickerOpen) e.preventDefault();
}}
className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 dark:ring-0 bg-muted dark:bg-muted text-foreground focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5 select-none" className="max-w-3xl w-[95vw] sm:w-full h-[75vh] sm:h-[85vh] flex flex-col p-0 gap-0 overflow-hidden border border-border ring-0 dark:ring-0 bg-muted dark:bg-muted text-foreground focus:outline-none focus:ring-0 focus-visible:outline-none focus-visible:ring-0 [&>button]:right-4 sm:[&>button]:right-12 [&>button]:top-6 sm:[&>button]:top-10 [&>button]:opacity-80 hover:[&>button]:opacity-100 [&>button_svg]:size-5 select-none"
> >
<DialogTitle className="sr-only">Manage Connectors</DialogTitle> <DialogTitle className="sr-only">Manage Connectors</DialogTitle>
@ -336,20 +352,26 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
}} }}
onDisconnect={() => handleDisconnectConnector(() => refreshConnectors())} onDisconnect={() => handleDisconnectConnector(() => refreshConnectors())}
onBack={handleBackFromEdit} onBack={handleBackFromEdit}
onQuickIndex={ onQuickIndex={(() => {
editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" const cfg = connectorConfig || editingConnector.config;
? () => { const isDrive = editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
startIndexing(editingConnector.id); editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR";
handleQuickIndexConnector( const hasDriveItems = isDrive
editingConnector.id, ? ((cfg?.selected_folders as unknown[]) ?? []).length > 0 ||
editingConnector.connector_type, ((cfg?.selected_files as unknown[]) ?? []).length > 0
stopIndexing, : true;
startDate, if (!hasDriveItems) return undefined;
endDate return () => {
); startIndexing(editingConnector.id);
} handleQuickIndexConnector(
: undefined editingConnector.id,
} editingConnector.connector_type,
stopIndexing,
startDate,
endDate
);
};
})()}
onConfigChange={setConnectorConfig} onConfigChange={setConnectorConfig}
onNameChange={setConnectorName} onNameChange={setConnectorName}
/> />

View file

@ -1,5 +1,6 @@
"use client"; "use client";
import { useAtomValue } from "jotai";
import { import {
ChevronDown, ChevronDown,
ChevronRight, ChevronRight,
@ -10,10 +11,13 @@ import {
Image, Image,
Loader2, Loader2,
Presentation, Presentation,
RefreshCw,
X, X,
} from "lucide-react"; } from "lucide-react";
import type { FC } from "react"; import type { FC } from "react";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { toast } from "sonner";
import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { Label } from "@/components/ui/label"; import { Label } from "@/components/ui/label";
import { import {
@ -23,7 +27,9 @@ import {
SelectTrigger, SelectTrigger,
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { Spinner } from "@/components/ui/spinner";
import { Switch } from "@/components/ui/switch"; import { Switch } from "@/components/ui/switch";
import { authenticatedFetch } from "@/lib/auth-utils";
import { type PickerResult, useGooglePicker } from "@/hooks/use-google-picker"; import { type PickerResult, useGooglePicker } from "@/hooks/use-google-picker";
import type { ConnectorConfigProps } from "../index"; import type { ConnectorConfigProps } from "../index";
@ -84,7 +90,10 @@ function getFileIconFromName(fileName: string, className: string = "size-3.5 shr
return <File className={`${className} text-gray-500`} />; return <File className={`${className} text-gray-500`} />;
} }
const DRIVE_REAUTH_ENDPOINT = "/api/v1/auth/google/drive/connector/reauth";
export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfigChange }) => { export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfigChange }) => {
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
const existingFolders = (connector.config?.selected_folders as SelectedItem[] | undefined) || []; const existingFolders = (connector.config?.selected_folders as SelectedItem[] | undefined) || [];
const existingFiles = (connector.config?.selected_files as SelectedItem[] | undefined) || []; const existingFiles = (connector.config?.selected_files as SelectedItem[] | undefined) || [];
const existingIndexingOptions = const existingIndexingOptions =
@ -93,6 +102,33 @@ export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfi
const [selectedFolders, setSelectedFolders] = useState<SelectedItem[]>(existingFolders); const [selectedFolders, setSelectedFolders] = useState<SelectedItem[]>(existingFolders);
const [selectedFiles, setSelectedFiles] = useState<SelectedItem[]>(existingFiles); const [selectedFiles, setSelectedFiles] = useState<SelectedItem[]>(existingFiles);
const [indexingOptions, setIndexingOptions] = useState<IndexingOptions>(existingIndexingOptions); const [indexingOptions, setIndexingOptions] = useState<IndexingOptions>(existingIndexingOptions);
const [reauthing, setReauthing] = useState(false);
const handleReauth = useCallback(async () => {
if (!searchSpaceId) return;
setReauthing(true);
try {
const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000";
const url = new URL(`${backendUrl}${DRIVE_REAUTH_ENDPOINT}`);
url.searchParams.set("connector_id", String(connector.id));
url.searchParams.set("space_id", String(searchSpaceId));
url.searchParams.set("return_url", window.location.pathname);
const response = await authenticatedFetch(url.toString());
if (!response.ok) {
const data = await response.json().catch(() => ({}));
toast.error(data.detail ?? "Failed to initiate re-authentication.");
return;
}
const data = await response.json();
if (data.auth_url) {
window.location.href = data.auth_url;
}
} catch {
toast.error("Failed to initiate re-authentication.");
} finally {
setReauthing(false);
}
}, [searchSpaceId, connector.id]);
useEffect(() => { useEffect(() => {
const folders = (connector.config?.selected_folders as SelectedItem[] | undefined) || []; const folders = (connector.config?.selected_folders as SelectedItem[] | undefined) || [];
@ -141,6 +177,10 @@ export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfi
onPicked: handlePicked, onPicked: handlePicked,
}); });
const isAuthExpired =
connector.config?.auth_expired === true ||
(!!pickerError && pickerError.toLowerCase().includes("authentication expired"));
const handleIndexingOptionChange = (key: keyof IndexingOptions, value: number | boolean) => { const handleIndexingOptionChange = (key: keyof IndexingOptions, value: number | boolean) => {
const newOptions = { ...indexingOptions, [key]: value }; const newOptions = { ...indexingOptions, [key]: value };
setIndexingOptions(newOptions); setIndexingOptions(newOptions);
@ -229,18 +269,44 @@ export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfi
</div> </div>
)} )}
<Button <Button
type="button" type="button"
variant="outline" variant="outline"
onClick={openPicker} onClick={openPicker}
disabled={pickerLoading} disabled={pickerLoading || isAuthExpired}
className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-white/10 text-xs sm:text-sm h-8 sm:h-9" className="bg-slate-400/5 dark:bg-white/5 border-slate-400/20 hover:bg-slate-400/10 dark:hover:bg-white/10 text-xs sm:text-sm h-8 sm:h-9"
> >
{pickerLoading && <Loader2 className="size-3.5 mr-1.5 animate-spin" />} {pickerLoading && <Loader2 className="size-3.5 mr-1.5 animate-spin" />}
{totalSelected > 0 ? "Change Selection" : "Select from Google Drive"} {totalSelected > 0 ? "Change Selection" : "Select from Google Drive"}
</Button> </Button>
{pickerError && <p className="text-xs text-destructive">{pickerError}</p>} {(pickerError || isAuthExpired) && (
<div className="flex flex-col gap-2">
{pickerError && !isAuthExpired && (
<p className="text-xs text-destructive">{pickerError}</p>
)}
{isAuthExpired && (
<div className="flex items-center gap-2">
<p className="text-xs text-amber-600 dark:text-amber-500">
Your Google Drive authentication has expired.
</p>
<Button
size="sm"
className="h-7 text-[11px] px-3 rounded-lg font-medium bg-amber-600 hover:bg-amber-700 text-white border-0 shadow-xs shrink-0"
onClick={handleReauth}
disabled={reauthing}
>
{reauthing ? (
<Spinner size="xs" />
) : (
<RefreshCw className="size-3.5" />
)}
Re-authenticate
</Button>
</div>
)}
</div>
)}
</div> </div>
{/* Indexing Options */} {/* Indexing Options */}

View file

@ -169,10 +169,9 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
</p> </p>
</div> </div>
</div> </div>
{/* Quick Index Button - only show for indexable connectors, but not for Google Drive (requires folder selection) */} {/* Quick Index Button - shown for indexable connectors that have been configured */}
{connector.is_indexable && {connector.is_indexable &&
onQuickIndex && onQuickIndex && (
connector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" && (
<Button <Button
variant="secondary" variant="secondary"
size="sm" size="sm"

View file

@ -19,6 +19,9 @@ import { getConnectorDisplayName } from "../tabs/all-connectors-tab";
const REAUTH_ENDPOINTS: Partial<Record<string, string>> = { const REAUTH_ENDPOINTS: Partial<Record<string, string>> = {
[EnumConnectorName.LINEAR_CONNECTOR]: "/api/v1/auth/linear/connector/reauth", [EnumConnectorName.LINEAR_CONNECTOR]: "/api/v1/auth/linear/connector/reauth",
[EnumConnectorName.NOTION_CONNECTOR]: "/api/v1/auth/notion/connector/reauth", [EnumConnectorName.NOTION_CONNECTOR]: "/api/v1/auth/notion/connector/reauth",
[EnumConnectorName.GOOGLE_DRIVE_CONNECTOR]: "/api/v1/auth/google/drive/connector/reauth",
[EnumConnectorName.GOOGLE_GMAIL_CONNECTOR]: "/api/v1/auth/google/gmail/connector/reauth",
[EnumConnectorName.GOOGLE_CALENDAR_CONNECTOR]: "/api/v1/auth/google/calendar/connector/reauth",
}; };
interface ConnectorAccountsListViewProps { interface ConnectorAccountsListViewProps {

View file

@ -104,73 +104,77 @@ export function useGooglePicker({ connectorId, onPicked }: UseGooglePickerOption
setError(null); setError(null);
try { try {
const [tokenData] = await Promise.all([ const [tokenData] = await Promise.all([
connectorsApiService.getDrivePickerToken(connectorId), connectorsApiService.getDrivePickerToken(connectorId),
loadPickerScript().then(() => loadPickerApi()), loadPickerScript().then(() => loadPickerApi()),
]); ]);
const { access_token, picker_api_key } = tokenData; const { access_token, picker_api_key } = tokenData;
const docsView = new google.picker.DocsView(google.picker.ViewId.DOCS) const docsView = new google.picker.DocsView(google.picker.ViewId.DOCS)
.setIncludeFolders(true) .setIncludeFolders(true)
.setSelectFolderEnabled(true); .setSelectFolderEnabled(true);
const builder = new google.picker.PickerBuilder() const builder = new google.picker.PickerBuilder()
.addView(docsView) .addView(docsView)
.enableFeature(google.picker.Feature.MULTISELECT_ENABLED) .enableFeature(google.picker.Feature.MULTISELECT_ENABLED)
.setOAuthToken(access_token) .setOAuthToken(access_token)
.setOrigin(window.location.protocol + "//" + window.location.host) .setOrigin(window.location.protocol + "//" + window.location.host)
.setTitle("Select files and folders to index"); .setTitle("Select files and folders to index");
if (picker_api_key) { if (picker_api_key) {
builder.setDeveloperKey(picker_api_key); builder.setDeveloperKey(picker_api_key);
} }
const picker = builder if (window.innerWidth < 640) {
.setCallback((data: google.picker.ResponseObject) => { builder.setSize(window.innerWidth - 32, window.innerHeight * 0.75);
const action = data[google.picker.Response.ACTION]; }
if (action === google.picker.Action.PICKED) { const picker = builder
const docs = data[google.picker.Response.DOCUMENTS]; .setCallback((data: google.picker.ResponseObject) => {
if (docs) { const action = data[google.picker.Response.ACTION];
const folders: PickerItem[] = [];
const files: PickerItem[] = [];
for (const doc of docs) { if (action === google.picker.Action.PICKED) {
const mimeType = doc[google.picker.Document.MIME_TYPE] ?? ""; const docs = data[google.picker.Response.DOCUMENTS];
const item: PickerItem = { if (docs) {
id: doc[google.picker.Document.ID], const folders: PickerItem[] = [];
name: doc[google.picker.Document.NAME] ?? "Untitled", const files: PickerItem[] = [];
mimeType,
}; for (const doc of docs) {
if (mimeType === FOLDER_MIME) { const mimeType = doc[google.picker.Document.MIME_TYPE] ?? "";
folders.push(item); const item: PickerItem = {
} else { id: doc[google.picker.Document.ID],
files.push(item); name: doc[google.picker.Document.NAME] ?? "Untitled",
} mimeType,
};
if (mimeType === FOLDER_MIME) {
folders.push(item);
} else {
files.push(item);
} }
onPickedRef.current({ folders, files });
} }
}
if (action === google.picker.Action.ERROR) { onPickedRef.current({ folders, files });
setError("Google Drive encountered an error. Please try again.");
} }
}
if ( if (action === google.picker.Action.ERROR) {
action === google.picker.Action.PICKED || setError("Google Drive encountered an error. Please try again.");
action === google.picker.Action.CANCEL || }
action === google.picker.Action.ERROR
) {
closePicker();
}
})
.build();
pickerRef.current = picker; if (
window.dispatchEvent(new Event(PICKER_OPEN_EVENT)); action === google.picker.Action.PICKED ||
picker.setVisible(true); action === google.picker.Action.CANCEL ||
action === google.picker.Action.ERROR
) {
closePicker();
}
})
.build();
pickerRef.current = picker;
window.dispatchEvent(new Event(PICKER_OPEN_EVENT));
picker.setVisible(true);
} catch (err) { } catch (err) {
window.dispatchEvent(new Event(PICKER_CLOSE_EVENT)); window.dispatchEvent(new Event(PICKER_CLOSE_EVENT));
openingRef.current = false; openingRef.current = false;