diff --git a/surfsense_backend/app/agents/new_chat/tools/google_drive/create_file.py b/surfsense_backend/app/agents/new_chat/tools/google_drive/create_file.py index ad7c1b9b1..90ee5ac5e 100644 --- a/surfsense_backend/app/agents/new_chat/tools/google_drive/create_file.py +++ b/surfsense_backend/app/agents/new_chat/tools/google_drive/create_file.py @@ -87,6 +87,15 @@ def create_create_google_drive_file_tool( logger.error(f"Failed to fetch creation context: {context['error']}") return {"status": "error", "message": context["error"]} + accounts = context.get("accounts", []) + if accounts and all(a.get("auth_expired") for a in accounts): + logger.warning("All Google Drive accounts have expired authentication") + return { + "status": "auth_error", + "message": "All connected Google Drive accounts need re-authentication. Please re-authenticate in your connector settings.", + "connector_type": "google_drive", + } + logger.info( f"Requesting approval for creating Google Drive file: name='{name}', type='{file_type}'" ) diff --git a/surfsense_backend/app/agents/new_chat/tools/google_drive/trash_file.py b/surfsense_backend/app/agents/new_chat/tools/google_drive/trash_file.py index 4d1c3c8de..3fcc2532a 100644 --- a/surfsense_backend/app/agents/new_chat/tools/google_drive/trash_file.py +++ b/surfsense_backend/app/agents/new_chat/tools/google_drive/trash_file.py @@ -76,6 +76,18 @@ def create_delete_google_drive_file_tool( logger.error(f"Failed to fetch trash context: {error_msg}") return {"status": "error", "message": error_msg} + account = context.get("account", {}) + if account.get("auth_expired"): + logger.warning( + "Google Drive account %s has expired authentication", + account.get("id"), + ) + return { + "status": "auth_error", + "message": "The Google Drive account for this file needs re-authentication. Please re-authenticate in your connector settings.", + "connector_type": "google_drive", + } + file = context["file"] file_id = file["file_id"] document_id = file.get("document_id") diff --git a/surfsense_backend/app/services/google_drive/tool_metadata_service.py b/surfsense_backend/app/services/google_drive/tool_metadata_service.py index e5da663ec..8438344e0 100644 --- a/surfsense_backend/app/services/google_drive/tool_metadata_service.py +++ b/surfsense_backend/app/services/google_drive/tool_metadata_service.py @@ -1,15 +1,21 @@ +import logging from dataclasses import dataclass from sqlalchemy import and_, func from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select +from sqlalchemy.orm.attributes import flag_modified +from app.connectors.google_drive.client import GoogleDriveClient from app.db import ( Document, DocumentType, SearchSourceConnector, SearchSourceConnectorType, ) +from app.utils.google_credentials import build_composio_credentials + +logger = logging.getLogger(__name__) @dataclass @@ -71,8 +77,17 @@ class GoogleDriveToolMetadataService: "error": "No Google Drive account connected", } + accounts_with_status = [] + for acc in accounts: + acc_dict = acc.to_dict() + auth_expired = await self._check_account_health(acc.id) + acc_dict["auth_expired"] = auth_expired + if auth_expired: + await self._persist_auth_expired(acc.id) + accounts_with_status.append(acc_dict) + return { - "accounts": [acc.to_dict() for acc in accounts], + "accounts": accounts_with_status, "supported_types": ["google_doc", "google_sheet"], } @@ -127,8 +142,14 @@ class GoogleDriveToolMetadataService: account = GoogleDriveAccount.from_connector(connector) file = GoogleDriveFile.from_document(document) + acc_dict = account.to_dict() + auth_expired = await self._check_account_health(connector.id) + acc_dict["auth_expired"] = auth_expired + if auth_expired: + await self._persist_auth_expired(connector.id) + return { - "account": account.to_dict(), + "account": acc_dict, "file": file.to_dict(), } @@ -151,3 +172,67 @@ class GoogleDriveToolMetadataService: ) connectors = result.scalars().all() return [GoogleDriveAccount.from_connector(c) for c in connectors] + + async def _check_account_health(self, connector_id: int) -> bool: + """Check if a Google Drive connector's credentials are still valid. + + Uses a lightweight ``files.list(pageSize=1)`` call to verify access. + + Returns True if the credentials are expired/invalid, False if healthy. + """ + try: + result = await self._db_session.execute( + select(SearchSourceConnector).where( + SearchSourceConnector.id == connector_id + ) + ) + connector = result.scalar_one_or_none() + if not connector: + return True + + pre_built_creds = None + if ( + connector.connector_type + == SearchSourceConnectorType.COMPOSIO_GOOGLE_DRIVE_CONNECTOR + ): + cca_id = connector.config.get("composio_connected_account_id") + if cca_id: + pre_built_creds = build_composio_credentials(cca_id) + + client = GoogleDriveClient( + session=self._db_session, + connector_id=connector_id, + credentials=pre_built_creds, + ) + await client.list_files( + query="trashed = false", page_size=1, fields="files(id)" + ) + return False + except Exception as e: + logger.warning( + "Google Drive connector %s health check failed: %s", + connector_id, + e, + ) + return True + + async def _persist_auth_expired(self, connector_id: int) -> None: + """Persist ``auth_expired: True`` to the connector config if not already set.""" + try: + result = await self._db_session.execute( + select(SearchSourceConnector).where( + SearchSourceConnector.id == connector_id + ) + ) + db_connector = result.scalar_one_or_none() + if db_connector and not db_connector.config.get("auth_expired"): + db_connector.config = {**db_connector.config, "auth_expired": True} + flag_modified(db_connector, "config") + await self._db_session.commit() + await self._db_session.refresh(db_connector) + except Exception: + logger.warning( + "Failed to persist auth_expired for connector %s", + connector_id, + exc_info=True, + ) diff --git a/surfsense_web/components/tool-ui/google-drive/create-file.tsx b/surfsense_web/components/tool-ui/google-drive/create-file.tsx index d72026d9e..063911296 100644 --- a/surfsense_web/components/tool-ui/google-drive/create-file.tsx +++ b/surfsense_web/components/tool-ui/google-drive/create-file.tsx @@ -30,6 +30,7 @@ import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom"; interface GoogleDriveAccount { id: number; name: string; + auth_expired?: boolean; } interface InterruptResult { @@ -69,11 +70,18 @@ interface InsufficientPermissionsResult { message: string; } +interface AuthErrorResult { + status: "auth_error"; + message: string; + connector_type?: string; +} + type CreateGoogleDriveFileResult = | InterruptResult | SuccessResult | ErrorResult - | InsufficientPermissionsResult; + | InsufficientPermissionsResult + | AuthErrorResult; function isInterruptResult(result: unknown): result is InterruptResult { return ( @@ -102,6 +110,15 @@ function isInsufficientPermissionsResult(result: unknown): result is Insufficien ); } +function isAuthErrorResult(result: unknown): result is AuthErrorResult { + return ( + typeof result === "object" && + result !== null && + "status" in result && + (result as AuthErrorResult).status === "auth_error" + ); +} + const FILE_TYPE_LABELS: Record = { google_doc: "Google Doc", google_sheet: "Google Sheet", @@ -127,11 +144,13 @@ function ApprovalCard({ const openHitlEditPanel = useSetAtom(openHitlEditPanelAtom); const accounts = interruptData.context?.accounts ?? []; + const validAccounts = accounts.filter(a => !a.auth_expired); + const expiredAccounts = accounts.filter(a => a.auth_expired); const defaultAccountId = useMemo(() => { - if (accounts.length === 1) return String(accounts[0].id); + if (validAccounts.length === 1) return String(validAccounts[0].id); return ""; - }, [accounts]); + }, [validAccounts]); const [selectedAccountId, setSelectedAccountId] = useState(defaultAccountId); const [selectedFileType, setSelectedFileType] = useState(args.file_type ?? "google_doc"); @@ -245,25 +264,33 @@ function ApprovalCard({

{interruptData.context.error}

) : ( <> - {accounts.length > 0 && ( -
-

- Google Drive Account * -

- -
- )} + {accounts.length > 0 && ( +
+

+ Google Drive Account * +

+ +
+ )}

@@ -435,6 +462,22 @@ function ErrorCard({ result }: { result: ErrorResult }) { ); } +function AuthErrorCard({ result }: { result: AuthErrorResult }) { + return ( +

+
+

+ Google Drive authentication expired +

+
+
+
+

{result.message}

+
+
+ ); +} + function SuccessCard({ result }: { result: SuccessResult }) { return (
@@ -506,6 +549,8 @@ export const CreateGoogleDriveFileToolUI = makeAssistantToolUI< return null; } + if (isAuthErrorResult(result)) return ; + if (isInsufficientPermissionsResult(result)) return ; diff --git a/surfsense_web/components/tool-ui/google-drive/trash-file.tsx b/surfsense_web/components/tool-ui/google-drive/trash-file.tsx index e43fc2c63..a556320b9 100644 --- a/surfsense_web/components/tool-ui/google-drive/trash-file.tsx +++ b/surfsense_web/components/tool-ui/google-drive/trash-file.tsx @@ -19,6 +19,7 @@ import { authenticatedFetch } from "@/lib/auth-utils"; interface GoogleDriveAccount { id: number; name: string; + auth_expired?: boolean; } interface GoogleDriveFile { @@ -76,13 +77,20 @@ interface InsufficientPermissionsResult { message: string; } +interface AuthErrorResult { + status: "auth_error"; + message: string; + connector_type?: string; +} + type DeleteGoogleDriveFileResult = | InterruptResult | SuccessResult | WarningResult | ErrorResult | NotFoundResult - | InsufficientPermissionsResult; + | InsufficientPermissionsResult + | AuthErrorResult; function isInterruptResult(result: unknown): result is InterruptResult { return ( @@ -131,6 +139,15 @@ function isInsufficientPermissionsResult(result: unknown): result is Insufficien ); } +function isAuthErrorResult(result: unknown): result is AuthErrorResult { + return ( + typeof result === "object" && + result !== null && + "status" in result && + (result as AuthErrorResult).status === "auth_error" + ); +} + const MIME_TYPE_LABELS: Record = { "application/vnd.google-apps.document": "Google Doc", "application/vnd.google-apps.spreadsheet": "Google Sheet", @@ -363,6 +380,22 @@ function InsufficientPermissionsCard({ result }: { result: InsufficientPermissio ); } +function AuthErrorCard({ result }: { result: AuthErrorResult }) { + return ( +
+
+

+ Google Drive authentication expired +

+
+
+
+

{result.message}

+
+
+ ); +} + function WarningCard({ result }: { result: WarningResult }) { return (
@@ -464,6 +497,8 @@ export const DeleteGoogleDriveFileToolUI = makeAssistantToolUI< return null; } + if (isAuthErrorResult(result)) return ; + if (isInsufficientPermissionsResult(result)) return ; diff --git a/surfsense_web/components/tool-ui/linear/create-linear-issue.tsx b/surfsense_web/components/tool-ui/linear/create-linear-issue.tsx index a9090db99..d0d41ce44 100644 --- a/surfsense_web/components/tool-ui/linear/create-linear-issue.tsx +++ b/surfsense_web/components/tool-ui/linear/create-linear-issue.tsx @@ -507,7 +507,7 @@ function AuthErrorCard({ result }: { result: AuthErrorResult }) {

- Linear authentication expired + All Linear accounts expired

diff --git a/surfsense_web/components/tool-ui/notion/delete-notion-page.tsx b/surfsense_web/components/tool-ui/notion/delete-notion-page.tsx index 222ddcaa8..d04901d7a 100644 --- a/surfsense_web/components/tool-ui/notion/delete-notion-page.tsx +++ b/surfsense_web/components/tool-ui/notion/delete-notion-page.tsx @@ -282,7 +282,7 @@ function AuthErrorCard({ result }: { result: AuthErrorResult }) {

- Notion authentication expired + All Notion accounts expired