mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-05-11 00:32:38 +02:00
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:
parent
36f4709225
commit
c9deae940c
9 changed files with 432 additions and 98 deletions
|
|
@ -10,8 +10,10 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
|||
from fastapi.responses import RedirectResponse
|
||||
from google_auth_oauthlib.flow import Flow
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from app.config import config
|
||||
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
|
||||
|
||||
|
||||
@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")
|
||||
async def calendar_callback(
|
||||
request: Request,
|
||||
|
|
@ -197,6 +259,42 @@ async def calendar_callback(
|
|||
# Mark that credentials are encrypted for backward compatibility
|
||||
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)
|
||||
is_duplicate = await check_duplicate_connector(
|
||||
session,
|
||||
|
|
|
|||
|
|
@ -525,6 +525,21 @@ async def list_google_drive_folders(
|
|||
raise
|
||||
except Exception as e:
|
||||
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(
|
||||
status_code=500, detail=f"Failed to list Drive contents: {e!s}"
|
||||
) from e
|
||||
|
|
|
|||
|
|
@ -10,8 +10,10 @@ from fastapi import APIRouter, Depends, HTTPException, Request
|
|||
from fastapi.responses import RedirectResponse
|
||||
from google_auth_oauthlib.flow import Flow
|
||||
from pydantic import ValidationError
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.exc import IntegrityError
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
from sqlalchemy.orm.attributes import flag_modified
|
||||
|
||||
from app.config import config
|
||||
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
|
||||
|
||||
|
||||
@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")
|
||||
async def gmail_callback(
|
||||
request: Request,
|
||||
|
|
@ -228,6 +290,42 @@ async def gmail_callback(
|
|||
# Mark that credentials are encrypted for backward compatibility
|
||||
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)
|
||||
is_duplicate = await check_duplicate_connector(
|
||||
session,
|
||||
|
|
|
|||
|
|
@ -956,23 +956,46 @@ async def index_connector_content(
|
|||
index_google_drive_files_task,
|
||||
)
|
||||
|
||||
if not drive_items or not drive_items.has_items():
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Google Drive indexing requires drive_items body parameter with folders or files",
|
||||
if drive_items and drive_items.has_items():
|
||||
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)}"
|
||||
)
|
||||
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(
|
||||
connector_id,
|
||||
search_space_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."
|
||||
|
||||
|
|
@ -3233,6 +3256,12 @@ async def get_drive_picker_token(
|
|||
raise
|
||||
except Exception as e:
|
||||
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(
|
||||
status_code=500,
|
||||
detail="Failed to retrieve access token. Check server logs for details.",
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@
|
|||
import { useAtomValue, useSetAtom } from "jotai";
|
||||
import { AlertTriangle, Cable, Settings } from "lucide-react";
|
||||
import { forwardRef, useEffect, useImperativeHandle, useMemo, useState } from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
import { documentTypeCountsAtom } from "@/atoms/documents/document-query.atoms";
|
||||
import { statusInboxItemsAtom } from "@/atoms/inbox/status-inbox.atom";
|
||||
import {
|
||||
|
|
@ -215,11 +216,8 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
return (
|
||||
<Dialog
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && pickerOpen) return;
|
||||
handleOpenChange(open);
|
||||
}}
|
||||
modal={!pickerOpen}
|
||||
modal={false}
|
||||
onOpenChange={handleOpenChange}
|
||||
>
|
||||
{showTrigger && (
|
||||
<TooltipIconButton
|
||||
|
|
@ -256,8 +254,26 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
</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
|
||||
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"
|
||||
>
|
||||
<DialogTitle className="sr-only">Manage Connectors</DialogTitle>
|
||||
|
|
@ -336,20 +352,26 @@ export const ConnectorIndicator = forwardRef<ConnectorIndicatorHandle, Connector
|
|||
}}
|
||||
onDisconnect={() => handleDisconnectConnector(() => refreshConnectors())}
|
||||
onBack={handleBackFromEdit}
|
||||
onQuickIndex={
|
||||
editingConnector.connector_type !== "GOOGLE_DRIVE_CONNECTOR"
|
||||
? () => {
|
||||
startIndexing(editingConnector.id);
|
||||
handleQuickIndexConnector(
|
||||
editingConnector.id,
|
||||
editingConnector.connector_type,
|
||||
stopIndexing,
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
}
|
||||
: undefined
|
||||
}
|
||||
onQuickIndex={(() => {
|
||||
const cfg = connectorConfig || editingConnector.config;
|
||||
const isDrive = editingConnector.connector_type === "GOOGLE_DRIVE_CONNECTOR" ||
|
||||
editingConnector.connector_type === "COMPOSIO_GOOGLE_DRIVE_CONNECTOR";
|
||||
const hasDriveItems = isDrive
|
||||
? ((cfg?.selected_folders as unknown[]) ?? []).length > 0 ||
|
||||
((cfg?.selected_files as unknown[]) ?? []).length > 0
|
||||
: true;
|
||||
if (!hasDriveItems) return undefined;
|
||||
return () => {
|
||||
startIndexing(editingConnector.id);
|
||||
handleQuickIndexConnector(
|
||||
editingConnector.id,
|
||||
editingConnector.connector_type,
|
||||
stopIndexing,
|
||||
startDate,
|
||||
endDate
|
||||
);
|
||||
};
|
||||
})()}
|
||||
onConfigChange={setConnectorConfig}
|
||||
onNameChange={setConnectorName}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
"use client";
|
||||
|
||||
import { useAtomValue } from "jotai";
|
||||
import {
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
|
|
@ -10,10 +11,13 @@ import {
|
|||
Image,
|
||||
Loader2,
|
||||
Presentation,
|
||||
RefreshCw,
|
||||
X,
|
||||
} from "lucide-react";
|
||||
import type { FC } 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 { Label } from "@/components/ui/label";
|
||||
import {
|
||||
|
|
@ -23,7 +27,9 @@ import {
|
|||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { Spinner } from "@/components/ui/spinner";
|
||||
import { Switch } from "@/components/ui/switch";
|
||||
import { authenticatedFetch } from "@/lib/auth-utils";
|
||||
import { type PickerResult, useGooglePicker } from "@/hooks/use-google-picker";
|
||||
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`} />;
|
||||
}
|
||||
|
||||
const DRIVE_REAUTH_ENDPOINT = "/api/v1/auth/google/drive/connector/reauth";
|
||||
|
||||
export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfigChange }) => {
|
||||
const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom);
|
||||
const existingFolders = (connector.config?.selected_folders as SelectedItem[] | undefined) || [];
|
||||
const existingFiles = (connector.config?.selected_files as SelectedItem[] | undefined) || [];
|
||||
const existingIndexingOptions =
|
||||
|
|
@ -93,6 +102,33 @@ export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfi
|
|||
const [selectedFolders, setSelectedFolders] = useState<SelectedItem[]>(existingFolders);
|
||||
const [selectedFiles, setSelectedFiles] = useState<SelectedItem[]>(existingFiles);
|
||||
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(() => {
|
||||
const folders = (connector.config?.selected_folders as SelectedItem[] | undefined) || [];
|
||||
|
|
@ -141,6 +177,10 @@ export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfi
|
|||
onPicked: handlePicked,
|
||||
});
|
||||
|
||||
const isAuthExpired =
|
||||
connector.config?.auth_expired === true ||
|
||||
(!!pickerError && pickerError.toLowerCase().includes("authentication expired"));
|
||||
|
||||
const handleIndexingOptionChange = (key: keyof IndexingOptions, value: number | boolean) => {
|
||||
const newOptions = { ...indexingOptions, [key]: value };
|
||||
setIndexingOptions(newOptions);
|
||||
|
|
@ -229,18 +269,44 @@ export const GoogleDriveConfig: FC<ConnectorConfigProps> = ({ connector, onConfi
|
|||
</div>
|
||||
)}
|
||||
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={openPicker}
|
||||
disabled={pickerLoading}
|
||||
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" />}
|
||||
{totalSelected > 0 ? "Change Selection" : "Select from Google Drive"}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={openPicker}
|
||||
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"
|
||||
>
|
||||
{pickerLoading && <Loader2 className="size-3.5 mr-1.5 animate-spin" />}
|
||||
{totalSelected > 0 ? "Change Selection" : "Select from Google Drive"}
|
||||
</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>
|
||||
|
||||
{/* Indexing Options */}
|
||||
|
|
|
|||
|
|
@ -169,10 +169,9 @@ export const ConnectorEditView: FC<ConnectorEditViewProps> = ({
|
|||
</p>
|
||||
</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 &&
|
||||
onQuickIndex &&
|
||||
connector.connector_type !== "GOOGLE_DRIVE_CONNECTOR" && (
|
||||
onQuickIndex && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
|
|
|
|||
|
|
@ -19,6 +19,9 @@ import { getConnectorDisplayName } from "../tabs/all-connectors-tab";
|
|||
const REAUTH_ENDPOINTS: Partial<Record<string, string>> = {
|
||||
[EnumConnectorName.LINEAR_CONNECTOR]: "/api/v1/auth/linear/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 {
|
||||
|
|
|
|||
|
|
@ -104,73 +104,77 @@ export function useGooglePicker({ connectorId, onPicked }: UseGooglePickerOption
|
|||
setError(null);
|
||||
|
||||
try {
|
||||
const [tokenData] = await Promise.all([
|
||||
connectorsApiService.getDrivePickerToken(connectorId),
|
||||
loadPickerScript().then(() => loadPickerApi()),
|
||||
]);
|
||||
const [tokenData] = await Promise.all([
|
||||
connectorsApiService.getDrivePickerToken(connectorId),
|
||||
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)
|
||||
.setIncludeFolders(true)
|
||||
.setSelectFolderEnabled(true);
|
||||
const docsView = new google.picker.DocsView(google.picker.ViewId.DOCS)
|
||||
.setIncludeFolders(true)
|
||||
.setSelectFolderEnabled(true);
|
||||
|
||||
const builder = new google.picker.PickerBuilder()
|
||||
.addView(docsView)
|
||||
.enableFeature(google.picker.Feature.MULTISELECT_ENABLED)
|
||||
.setOAuthToken(access_token)
|
||||
.setOrigin(window.location.protocol + "//" + window.location.host)
|
||||
.setTitle("Select files and folders to index");
|
||||
const builder = new google.picker.PickerBuilder()
|
||||
.addView(docsView)
|
||||
.enableFeature(google.picker.Feature.MULTISELECT_ENABLED)
|
||||
.setOAuthToken(access_token)
|
||||
.setOrigin(window.location.protocol + "//" + window.location.host)
|
||||
.setTitle("Select files and folders to index");
|
||||
|
||||
if (picker_api_key) {
|
||||
builder.setDeveloperKey(picker_api_key);
|
||||
}
|
||||
if (picker_api_key) {
|
||||
builder.setDeveloperKey(picker_api_key);
|
||||
}
|
||||
|
||||
const picker = builder
|
||||
.setCallback((data: google.picker.ResponseObject) => {
|
||||
const action = data[google.picker.Response.ACTION];
|
||||
if (window.innerWidth < 640) {
|
||||
builder.setSize(window.innerWidth - 32, window.innerHeight * 0.75);
|
||||
}
|
||||
|
||||
if (action === google.picker.Action.PICKED) {
|
||||
const docs = data[google.picker.Response.DOCUMENTS];
|
||||
if (docs) {
|
||||
const folders: PickerItem[] = [];
|
||||
const files: PickerItem[] = [];
|
||||
const picker = builder
|
||||
.setCallback((data: google.picker.ResponseObject) => {
|
||||
const action = data[google.picker.Response.ACTION];
|
||||
|
||||
for (const doc of docs) {
|
||||
const mimeType = doc[google.picker.Document.MIME_TYPE] ?? "";
|
||||
const item: PickerItem = {
|
||||
id: doc[google.picker.Document.ID],
|
||||
name: doc[google.picker.Document.NAME] ?? "Untitled",
|
||||
mimeType,
|
||||
};
|
||||
if (mimeType === FOLDER_MIME) {
|
||||
folders.push(item);
|
||||
} else {
|
||||
files.push(item);
|
||||
}
|
||||
if (action === google.picker.Action.PICKED) {
|
||||
const docs = data[google.picker.Response.DOCUMENTS];
|
||||
if (docs) {
|
||||
const folders: PickerItem[] = [];
|
||||
const files: PickerItem[] = [];
|
||||
|
||||
for (const doc of docs) {
|
||||
const mimeType = doc[google.picker.Document.MIME_TYPE] ?? "";
|
||||
const item: PickerItem = {
|
||||
id: doc[google.picker.Document.ID],
|
||||
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) {
|
||||
setError("Google Drive encountered an error. Please try again.");
|
||||
onPickedRef.current({ folders, files });
|
||||
}
|
||||
}
|
||||
|
||||
if (
|
||||
action === google.picker.Action.PICKED ||
|
||||
action === google.picker.Action.CANCEL ||
|
||||
action === google.picker.Action.ERROR
|
||||
) {
|
||||
closePicker();
|
||||
}
|
||||
})
|
||||
.build();
|
||||
if (action === google.picker.Action.ERROR) {
|
||||
setError("Google Drive encountered an error. Please try again.");
|
||||
}
|
||||
|
||||
pickerRef.current = picker;
|
||||
window.dispatchEvent(new Event(PICKER_OPEN_EVENT));
|
||||
picker.setVisible(true);
|
||||
if (
|
||||
action === google.picker.Action.PICKED ||
|
||||
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) {
|
||||
window.dispatchEvent(new Event(PICKER_CLOSE_EVENT));
|
||||
openingRef.current = false;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue