diff --git a/surfsense_backend/app/routes/linear_add_connector_route.py b/surfsense_backend/app/routes/linear_add_connector_route.py index 40b800e3b..b5974c83b 100644 --- a/surfsense_backend/app/routes/linear_add_connector_route.py +++ b/surfsense_backend/app/routes/linear_add_connector_route.py @@ -15,6 +15,9 @@ from pydantic import ValidationError from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from sqlalchemy.orm.attributes import flag_modified + from app.config import config from app.connectors.linear_connector import fetch_linear_organization_name from app.db import ( @@ -335,8 +338,6 @@ async def linear_callback( reauth_return_url = data.get("return_url") if reauth_connector_id: - from sqlalchemy.orm.attributes import flag_modified - result = await session.execute( select(SearchSourceConnector).filter( SearchSourceConnector.id == reauth_connector_id, @@ -446,6 +447,22 @@ async def linear_callback( ) from e +async def _mark_connector_auth_expired( + session: AsyncSession, connector: SearchSourceConnector +) -> None: + """Persist auth_expired flag in the connector config so the frontend can show a re-auth prompt.""" + try: + connector.config = {**connector.config, "auth_expired": True} + flag_modified(connector, "config") + await session.commit() + await session.refresh(connector) + except Exception: + logger.warning( + f"Failed to persist auth_expired flag for connector {connector.id}", + exc_info=True, + ) + + async def refresh_linear_token( session: AsyncSession, connector: SearchSourceConnector ) -> SearchSourceConnector: @@ -479,6 +496,7 @@ async def refresh_linear_token( ) from e if not refresh_token: + await _mark_connector_auth_expired(session, connector) raise HTTPException( status_code=400, detail="No refresh token available. Please re-authenticate.", @@ -521,6 +539,7 @@ async def refresh_linear_token( or "expired" in error_lower or "revoked" in error_lower ): + await _mark_connector_auth_expired(session, connector) raise HTTPException( status_code=401, detail="Linear authentication failed. Please re-authenticate.", @@ -557,10 +576,14 @@ async def refresh_linear_token( credentials.expires_at = expires_at credentials.scope = token_json.get("scope") - # Update connector config with encrypted tokens + # Update connector config with encrypted tokens, preserving non-credential fields credentials_dict = credentials.to_dict() credentials_dict["_token_encrypted"] = True + if connector.config.get("organization_name"): + credentials_dict["organization_name"] = connector.config["organization_name"] + credentials_dict.pop("auth_expired", None) connector.config = credentials_dict + flag_modified(connector, "config") await session.commit() await session.refresh(connector) diff --git a/surfsense_backend/app/routes/notion_add_connector_route.py b/surfsense_backend/app/routes/notion_add_connector_route.py index 953aa2bb8..d862a5855 100644 --- a/surfsense_backend/app/routes/notion_add_connector_route.py +++ b/surfsense_backend/app/routes/notion_add_connector_route.py @@ -15,6 +15,9 @@ from pydantic import ValidationError from sqlalchemy.exc import IntegrityError from sqlalchemy.ext.asyncio import AsyncSession +from sqlalchemy import select +from sqlalchemy.orm.attributes import flag_modified + from app.config import config from app.db import ( SearchSourceConnector, @@ -334,8 +337,6 @@ async def notion_callback( reauth_return_url = data.get("return_url") if reauth_connector_id: - from sqlalchemy.orm.attributes import flag_modified - result = await session.execute( select(SearchSourceConnector).filter( SearchSourceConnector.id == reauth_connector_id, @@ -448,6 +449,22 @@ async def notion_callback( ) from e +async def _mark_connector_auth_expired( + session: AsyncSession, connector: SearchSourceConnector +) -> None: + """Persist auth_expired flag in the connector config so the frontend can show a re-auth prompt.""" + try: + connector.config = {**connector.config, "auth_expired": True} + flag_modified(connector, "config") + await session.commit() + await session.refresh(connector) + except Exception: + logger.warning( + f"Failed to persist auth_expired flag for connector {connector.id}", + exc_info=True, + ) + + async def refresh_notion_token( session: AsyncSession, connector: SearchSourceConnector ) -> SearchSourceConnector: @@ -481,6 +498,7 @@ async def refresh_notion_token( ) from e if not refresh_token: + await _mark_connector_auth_expired(session, connector) raise HTTPException( status_code=400, detail="No refresh token available. Please re-authenticate.", @@ -523,6 +541,7 @@ async def refresh_notion_token( or "expired" in error_lower or "revoked" in error_lower ): + await _mark_connector_auth_expired(session, connector) raise HTTPException( status_code=401, detail="Notion authentication failed. Please re-authenticate.", @@ -571,7 +590,9 @@ async def refresh_notion_token( # Update connector config with encrypted tokens credentials_dict = credentials.to_dict() credentials_dict["_token_encrypted"] = True + credentials_dict.pop("auth_expired", None) connector.config = credentials_dict + flag_modified(connector, "config") await session.commit() await session.refresh(connector) diff --git a/surfsense_backend/app/routes/search_source_connectors_routes.py b/surfsense_backend/app/routes/search_source_connectors_routes.py index a813b2cc2..bd7d02b73 100644 --- a/surfsense_backend/app/routes/search_source_connectors_routes.py +++ b/surfsense_backend/app/routes/search_source_connectors_routes.py @@ -1236,6 +1236,48 @@ async def run_slack_indexing( ) +_AUTH_ERROR_PATTERNS = ( + "failed to refresh linear oauth", + "failed to refresh your notion connection", + "failed to refresh notion token", + "authentication failed", + "auth_expired", + "token has been expired or revoked", + "invalid_grant", +) + + +def _is_auth_error(error_message: str) -> bool: + """Check if an error message indicates an OAuth token expiry failure.""" + if not error_message: + return False + lower = error_message.lower() + return any(pattern in lower for pattern in _AUTH_ERROR_PATTERNS) + + +async def _persist_auth_expired(session: AsyncSession, connector_id: int) -> None: + """Flag a connector as auth_expired so the frontend shows a re-auth prompt.""" + from sqlalchemy.orm.attributes import flag_modified + + try: + result = await session.execute( + select(SearchSourceConnector).where( + SearchSourceConnector.id == connector_id + ) + ) + connector = result.scalar_one_or_none() + 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, + ) + + async def _run_indexing_with_notifications( session: AsyncSession, connector_id: int, @@ -1520,6 +1562,8 @@ async def _run_indexing_with_notifications( else: # Actual failure logger.error(f"Indexing failed: {error_or_warning}") + if _is_auth_error(str(error_or_warning)): + await _persist_auth_expired(session, connector_id) if notification: # Refresh notification to ensure it's not stale after indexing function commits await session.refresh(notification) @@ -1584,6 +1628,9 @@ async def _run_indexing_with_notifications( except Exception as e: logger.error(f"Error in indexing task: {e!s}", exc_info=True) + if _is_auth_error(str(e)): + await _persist_auth_expired(session, connector_id) + # Update notification on exception if notification: try: diff --git a/surfsense_backend/app/services/linear/tool_metadata_service.py b/surfsense_backend/app/services/linear/tool_metadata_service.py index 942e7bd92..bae8ab367 100644 --- a/surfsense_backend/app/services/linear/tool_metadata_service.py +++ b/surfsense_backend/app/services/linear/tool_metadata_service.py @@ -4,6 +4,7 @@ from dataclasses import dataclass from sqlalchemy import and_, func, or_ from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select +from sqlalchemy.orm.attributes import flag_modified from app.connectors.linear_connector import LinearConnector from app.db import ( @@ -118,6 +119,17 @@ class LinearToolMetadataService: workspace.name, e, ) + try: + connector.config = {**connector.config, "auth_expired": True} + flag_modified(connector, "config") + await self._db_session.commit() + await self._db_session.refresh(connector) + except Exception: + logger.warning( + "Failed to persist auth_expired for connector %s", + connector.id, + exc_info=True, + ) workspaces.append( { "id": workspace.id, diff --git a/surfsense_backend/app/services/notion/tool_metadata_service.py b/surfsense_backend/app/services/notion/tool_metadata_service.py index d152ee904..ca882c5c0 100644 --- a/surfsense_backend/app/services/notion/tool_metadata_service.py +++ b/surfsense_backend/app/services/notion/tool_metadata_service.py @@ -4,6 +4,7 @@ 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.notion_history import NotionHistoryConnector from app.db import ( @@ -92,6 +93,25 @@ class NotionToolMetadataService: acc_dict = acc.to_dict() auth_expired = await self._check_account_health(acc.id) acc_dict["auth_expired"] = auth_expired + if auth_expired: + try: + result = await self._db_session.execute( + select(SearchSourceConnector).where( + SearchSourceConnector.id == acc.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", + acc.id, + exc_info=True, + ) accounts_with_status.append(acc_dict) return { diff --git a/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx b/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx index 2f0e0eb5e..5952e3af3 100644 --- a/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx +++ b/surfsense_web/components/assistant-ui/connector-popup/views/connector-accounts-list-view.tsx @@ -1,17 +1,26 @@ "use client"; -import { ArrowLeft, Plus, Server } from "lucide-react"; -import type { FC } from "react"; +import { useAtomValue } from "jotai"; +import { ArrowLeft, Plus, RefreshCw, Server } from "lucide-react"; +import { type FC, useCallback, useState } from "react"; +import { toast } from "sonner"; +import { activeSearchSpaceIdAtom } from "@/atoms/search-spaces/search-space-query.atoms"; import { Button } from "@/components/ui/button"; import { Spinner } from "@/components/ui/spinner"; import { EnumConnectorName } from "@/contracts/enums/connector"; import { getConnectorIcon } from "@/contracts/enums/connectorIcons"; import type { SearchSourceConnector } from "@/contracts/types/connector.types"; +import { authenticatedFetch } from "@/lib/auth-utils"; import { formatRelativeDate } from "@/lib/format-date"; import { cn } from "@/lib/utils"; import { useConnectorStatus } from "../hooks/use-connector-status"; import { getConnectorDisplayName } from "../tabs/all-connectors-tab"; +const REAUTH_ENDPOINTS: Partial> = { + [EnumConnectorName.LINEAR_CONNECTOR]: "/api/v1/auth/linear/connector/reauth", + [EnumConnectorName.NOTION_CONNECTOR]: "/api/v1/auth/notion/connector/reauth", +}; + interface ConnectorAccountsListViewProps { connectorType: string; connectorTitle: string; @@ -43,12 +52,46 @@ export const ConnectorAccountsListView: FC = ({ isConnecting = false, addButtonText, }) => { + const searchSpaceId = useAtomValue(activeSearchSpaceIdAtom); + const [reauthingId, setReauthingId] = useState(null); + // Get connector status const { isConnectorEnabled, getConnectorStatusMessage } = useConnectorStatus(); const isEnabled = isConnectorEnabled(connectorType); const statusMessage = getConnectorStatusMessage(connectorType); + const reauthEndpoint = REAUTH_ENDPOINTS[connectorType]; + + const handleReauth = useCallback( + async (connectorId: number) => { + if (!searchSpaceId || !reauthEndpoint) return; + setReauthingId(connectorId); + try { + const backendUrl = process.env.NEXT_PUBLIC_FASTAPI_BACKEND_URL || "http://localhost:8000"; + const url = new URL(`${backendUrl}${reauthEndpoint}`); + url.searchParams.set("connector_id", String(connectorId)); + 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 { + setReauthingId(null); + } + }, + [searchSpaceId, reauthEndpoint] + ); + // Filter connectors to only show those of this type const typeConnectors = connectors.filter((c) => c.connector_type === connectorType); @@ -149,6 +192,8 @@ export const ConnectorAccountsListView: FC = ({
{typeConnectors.map((connector) => { const isIndexing = indexingConnectorIds.has(connector.id); + const isAuthExpired = + !!reauthEndpoint && connector.config?.auth_expired === true; return (
= ({

)}
- + {isAuthExpired ? ( + + ) : ( + + )}
); })} diff --git a/surfsense_web/components/tool-ui/linear/update-linear-issue.tsx b/surfsense_web/components/tool-ui/linear/update-linear-issue.tsx index 1163ab4ef..205d9c904 100644 --- a/surfsense_web/components/tool-ui/linear/update-linear-issue.tsx +++ b/surfsense_web/components/tool-ui/linear/update-linear-issue.tsx @@ -1,6 +1,7 @@ "use client"; import { makeAssistantToolUI } from "@assistant-ui/react"; +import { useSetAtom } from "jotai"; import { CornerDownLeftIcon, Pen } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; @@ -13,7 +14,6 @@ import { } from "@/components/ui/select"; import { PlateEditor } from "@/components/editor/plate-editor"; import { Spinner } from "@/components/ui/spinner"; -import { useSetAtom } from "jotai"; import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom"; interface LinearLabel { diff --git a/surfsense_web/components/tool-ui/notion/create-notion-page.tsx b/surfsense_web/components/tool-ui/notion/create-notion-page.tsx index 470d22b74..093c97d4b 100644 --- a/surfsense_web/components/tool-ui/notion/create-notion-page.tsx +++ b/surfsense_web/components/tool-ui/notion/create-notion-page.tsx @@ -1,6 +1,7 @@ "use client"; import { makeAssistantToolUI } from "@assistant-ui/react"; +import { useSetAtom } from "jotai"; import { CornerDownLeftIcon, Pen } from "lucide-react"; import { useCallback, useEffect, useMemo, useState } from "react"; import { Button } from "@/components/ui/button"; @@ -13,7 +14,6 @@ import { } from "@/components/ui/select"; import { PlateEditor } from "@/components/editor/plate-editor"; import { Spinner } from "@/components/ui/spinner"; -import { useSetAtom } from "jotai"; import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom"; interface InterruptResult { diff --git a/surfsense_web/components/tool-ui/notion/update-notion-page.tsx b/surfsense_web/components/tool-ui/notion/update-notion-page.tsx index 693b80945..58ce04dba 100644 --- a/surfsense_web/components/tool-ui/notion/update-notion-page.tsx +++ b/surfsense_web/components/tool-ui/notion/update-notion-page.tsx @@ -1,12 +1,12 @@ "use client"; import { makeAssistantToolUI } from "@assistant-ui/react"; +import { useSetAtom } from "jotai"; import { CornerDownLeftIcon, Pen } from "lucide-react"; import { useCallback, useEffect, useState } from "react"; import { Button } from "@/components/ui/button"; import { PlateEditor } from "@/components/editor/plate-editor"; import { Spinner } from "@/components/ui/spinner"; -import { useSetAtom } from "jotai"; import { openHitlEditPanelAtom } from "@/atoms/chat/hitl-edit-panel.atom"; interface InterruptResult {