diff --git a/surfsense_backend/app/agents/new_chat/tools/linear/create_issue.py b/surfsense_backend/app/agents/new_chat/tools/linear/create_issue.py index c4c72f3ac..781b1afe5 100644 --- a/surfsense_backend/app/agents/new_chat/tools/linear/create_issue.py +++ b/surfsense_backend/app/agents/new_chat/tools/linear/create_issue.py @@ -84,6 +84,15 @@ def create_create_linear_issue_tool( logger.error(f"Failed to fetch creation context: {context['error']}") return {"status": "error", "message": context["error"]} + workspaces = context.get("workspaces", []) + if workspaces and all(w.get("auth_expired") for w in workspaces): + logger.warning("All Linear accounts have expired authentication") + return { + "status": "auth_error", + "message": "All connected Linear accounts need re-authentication. Please re-authenticate in your connector settings.", + "connector_type": "linear", + } + logger.info(f"Requesting approval for creating Linear issue: '{title}'") approval = interrupt( { diff --git a/surfsense_backend/app/agents/new_chat/tools/linear/delete_issue.py b/surfsense_backend/app/agents/new_chat/tools/linear/delete_issue.py index d316f85e4..7f41e32e2 100644 --- a/surfsense_backend/app/agents/new_chat/tools/linear/delete_issue.py +++ b/surfsense_backend/app/agents/new_chat/tools/linear/delete_issue.py @@ -91,6 +91,14 @@ def create_delete_linear_issue_tool( if "error" in context: error_msg = context["error"] + if context.get("auth_expired"): + logger.warning(f"Auth expired for delete context: {error_msg}") + return { + "status": "auth_error", + "message": error_msg, + "connector_id": context.get("connector_id"), + "connector_type": "linear", + } if "not found" in error_msg.lower(): logger.warning(f"Issue not found: {error_msg}") return {"status": "not_found", "message": error_msg} diff --git a/surfsense_backend/app/agents/new_chat/tools/linear/update_issue.py b/surfsense_backend/app/agents/new_chat/tools/linear/update_issue.py index 760815354..19af851c1 100644 --- a/surfsense_backend/app/agents/new_chat/tools/linear/update_issue.py +++ b/surfsense_backend/app/agents/new_chat/tools/linear/update_issue.py @@ -103,6 +103,14 @@ def create_update_linear_issue_tool( if "error" in context: error_msg = context["error"] + if context.get("auth_expired"): + logger.warning(f"Auth expired for update context: {error_msg}") + return { + "status": "auth_error", + "message": error_msg, + "connector_id": context.get("connector_id"), + "connector_type": "linear", + } if "not found" in error_msg.lower(): logger.warning(f"Issue not found: {error_msg}") return {"status": "not_found", "message": error_msg} diff --git a/surfsense_backend/app/agents/new_chat/tools/notion/create_page.py b/surfsense_backend/app/agents/new_chat/tools/notion/create_page.py index 0ed773f3a..6cb720173 100644 --- a/surfsense_backend/app/agents/new_chat/tools/notion/create_page.py +++ b/surfsense_backend/app/agents/new_chat/tools/notion/create_page.py @@ -89,6 +89,15 @@ def create_create_notion_page_tool( "message": context["error"], } + accounts = context.get("accounts", []) + if accounts and all(a.get("auth_expired") for a in accounts): + logger.warning("All Notion accounts have expired authentication") + return { + "status": "auth_error", + "message": "All connected Notion accounts need re-authentication. Please re-authenticate in your connector settings.", + "connector_type": "notion", + } + logger.info(f"Requesting approval for creating Notion page: '{title}'") approval = interrupt( { diff --git a/surfsense_backend/app/agents/new_chat/tools/notion/delete_page.py b/surfsense_backend/app/agents/new_chat/tools/notion/delete_page.py index 8f0c97df8..f1a65a14a 100644 --- a/surfsense_backend/app/agents/new_chat/tools/notion/delete_page.py +++ b/surfsense_backend/app/agents/new_chat/tools/notion/delete_page.py @@ -262,6 +262,16 @@ def create_delete_notion_page_tool( raise logger.error(f"Error deleting Notion page: {e}", exc_info=True) + error_str = str(e).lower() + if isinstance(e, NotionAPIError) and ( + "401" in error_str or "unauthorized" in error_str + ): + return { + "status": "auth_error", + "message": str(e), + "connector_id": connector_id_from_context if "connector_id_from_context" in dir() else None, + "connector_type": "notion", + } if isinstance(e, ValueError | NotionAPIError): message = str(e) else: diff --git a/surfsense_backend/app/agents/new_chat/tools/notion/update_page.py b/surfsense_backend/app/agents/new_chat/tools/notion/update_page.py index ecf6fcd47..306cd4b68 100644 --- a/surfsense_backend/app/agents/new_chat/tools/notion/update_page.py +++ b/surfsense_backend/app/agents/new_chat/tools/notion/update_page.py @@ -264,6 +264,16 @@ def create_update_notion_page_tool( raise logger.error(f"Error updating Notion page: {e}", exc_info=True) + error_str = str(e).lower() + if isinstance(e, NotionAPIError) and ( + "401" in error_str or "unauthorized" in error_str + ): + return { + "status": "auth_error", + "message": str(e), + "connector_id": connector_id_from_context if "connector_id_from_context" in dir() else None, + "connector_type": "notion", + } if isinstance(e, ValueError | NotionAPIError): message = str(e) else: diff --git a/surfsense_backend/app/routes/linear_add_connector_route.py b/surfsense_backend/app/routes/linear_add_connector_route.py index dd5f7443c..119042668 100644 --- a/surfsense_backend/app/routes/linear_add_connector_route.py +++ b/surfsense_backend/app/routes/linear_add_connector_route.py @@ -127,6 +127,70 @@ async def connect_linear(space_id: int, user: User = Depends(current_active_user ) from e +@router.get("/auth/linear/connector/reauth") +async def reauth_linear( + space_id: int, + connector_id: int, + return_url: str | None = None, + user: User = Depends(current_active_user), + session: AsyncSession = Depends(get_async_session), +): + """Initiate Linear 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.LINEAR_CONNECTOR, + ) + ) + connector = result.scalars().first() + if not connector: + raise HTTPException( + status_code=404, + detail="Linear connector not found or access denied", + ) + + if not config.LINEAR_CLIENT_ID: + raise HTTPException(status_code=500, detail="Linear OAuth not configured.") + if not config.SECRET_KEY: + raise HTTPException( + status_code=500, detail="SECRET_KEY not configured for OAuth security." + ) + + 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) + + from urllib.parse import urlencode + + auth_params = { + "client_id": config.LINEAR_CLIENT_ID, + "response_type": "code", + "redirect_uri": config.LINEAR_REDIRECT_URI, + "scope": " ".join(SCOPES), + "state": state_encoded, + } + auth_url = f"{AUTHORIZATION_URL}?{urlencode(auth_params)}" + + logger.info( + f"Initiating Linear 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 Linear re-auth: {e!s}", exc_info=True) + raise HTTPException( + status_code=500, detail=f"Failed to initiate Linear re-auth: {e!s}" + ) from e + + @router.get("/auth/linear/connector/callback") async def linear_callback( request: Request, @@ -267,6 +331,45 @@ async def linear_callback( "_token_encrypted": True, } + reauth_connector_id = data.get("connector_id") + 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, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.search_space_id == space_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.LINEAR_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", + ) + + connector_config["organization_name"] = org_name + db_connector.config = connector_config + flag_modified(db_connector, "config") + await session.commit() + await session.refresh(db_connector) + + logger.info( + f"Re-authenticated Linear 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}/new-chat?modal=connectors&tab=all&success=true&connector=linear-connector&connectorId={db_connector.id}" + ) + # Check for duplicate connector (same organization already connected) is_duplicate = await check_duplicate_connector( session, @@ -292,6 +395,7 @@ async def linear_callback( org_name, ) # Create new connector + connector_config["organization_name"] = org_name new_connector = SearchSourceConnector( name=connector_name, connector_type=SearchSourceConnectorType.LINEAR_CONNECTOR, diff --git a/surfsense_backend/app/routes/notion_add_connector_route.py b/surfsense_backend/app/routes/notion_add_connector_route.py index 81017af50..46404acb0 100644 --- a/surfsense_backend/app/routes/notion_add_connector_route.py +++ b/surfsense_backend/app/routes/notion_add_connector_route.py @@ -124,6 +124,70 @@ async def connect_notion(space_id: int, user: User = Depends(current_active_user ) from e +@router.get("/auth/notion/connector/reauth") +async def reauth_notion( + space_id: int, + connector_id: int, + return_url: str | None = None, + user: User = Depends(current_active_user), + session: AsyncSession = Depends(get_async_session), +): + """Initiate Notion 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.NOTION_CONNECTOR, + ) + ) + connector = result.scalars().first() + if not connector: + raise HTTPException( + status_code=404, + detail="Notion connector not found or access denied", + ) + + if not config.NOTION_CLIENT_ID: + raise HTTPException(status_code=500, detail="Notion OAuth not configured.") + if not config.SECRET_KEY: + raise HTTPException( + status_code=500, detail="SECRET_KEY not configured for OAuth security." + ) + + 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) + + from urllib.parse import urlencode + + auth_params = { + "client_id": config.NOTION_CLIENT_ID, + "response_type": "code", + "owner": "user", + "redirect_uri": config.NOTION_REDIRECT_URI, + "state": state_encoded, + } + auth_url = f"{AUTHORIZATION_URL}?{urlencode(auth_params)}" + + logger.info( + f"Initiating Notion 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 Notion re-auth: {e!s}", exc_info=True) + raise HTTPException( + status_code=500, detail=f"Failed to initiate Notion re-auth: {e!s}" + ) from e + + @router.get("/auth/notion/connector/callback") async def notion_callback( request: Request, @@ -266,6 +330,44 @@ async def notion_callback( "_token_encrypted": True, } + reauth_connector_id = data.get("connector_id") + 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, + SearchSourceConnector.user_id == user_id, + SearchSourceConnector.search_space_id == space_id, + SearchSourceConnector.connector_type + == SearchSourceConnectorType.NOTION_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 = connector_config + flag_modified(db_connector, "config") + await session.commit() + await session.refresh(db_connector) + + logger.info( + f"Re-authenticated Notion 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}/new-chat?modal=connectors&tab=all&success=true&connector=notion-connector&connectorId={db_connector.id}" + ) + # Extract unique identifier from connector credentials connector_identifier = extract_identifier_from_credentials( SearchSourceConnectorType.NOTION_CONNECTOR, connector_config diff --git a/surfsense_backend/app/services/linear/tool_metadata_service.py b/surfsense_backend/app/services/linear/tool_metadata_service.py index 0309abdf3..942e7bd92 100644 --- a/surfsense_backend/app/services/linear/tool_metadata_service.py +++ b/surfsense_backend/app/services/linear/tool_metadata_service.py @@ -1,3 +1,4 @@ +import logging from dataclasses import dataclass from sqlalchemy import and_, func, or_ @@ -12,6 +13,8 @@ from app.db import ( SearchSourceConnectorType, ) +logger = logging.getLogger(__name__) + @dataclass class LinearWorkspace: @@ -109,7 +112,23 @@ class LinearToolMetadataService: priorities = await self._fetch_priority_values(linear_client) teams = await self._fetch_teams_context(linear_client) except Exception as e: - return {"error": f"Failed to fetch Linear context: {e!s}"} + logger.warning( + "Linear connector %s (%s) auth failed, flagging as expired: %s", + connector.id, + workspace.name, + e, + ) + workspaces.append( + { + "id": workspace.id, + "name": workspace.name, + "organization_name": workspace.organization_name, + "teams": [], + "priorities": [], + "auth_expired": True, + } + ) + continue workspaces.append( { "id": workspace.id, @@ -117,6 +136,7 @@ class LinearToolMetadataService: "organization_name": workspace.organization_name, "teams": teams, "priorities": priorities, + "auth_expired": False, } ) @@ -137,8 +157,8 @@ class LinearToolMetadataService: document = await self._resolve_issue(search_space_id, user_id, issue_ref) if not document: return { - "error": f"Issue '{issue_ref}' not found in your indexed Linear issues. " - "This could mean: (1) the issue doesn't exist, (2) it hasn't been indexed yet, " + "error": f"Issue '{issue_ref}' not found in your synced Linear issues. " + "This could mean: (1) the issue doesn't exist, (2) it hasn't been synced yet, " "or (3) the title or identifier is different." } @@ -157,6 +177,13 @@ class LinearToolMetadataService: priorities = await self._fetch_priority_values(linear_client) issue_api = await self._fetch_issue_context(linear_client, issue.id) except Exception as e: + error_str = str(e).lower() + if "401" in error_str or "authentication" in error_str or "re-authenticate" in error_str: + return { + "error": f"Failed to fetch Linear issue context: {e!s}", + "auth_expired": True, + "connector_id": connector.id, + } return {"error": f"Failed to fetch Linear issue context: {e!s}"} if not issue_api: @@ -210,8 +237,8 @@ class LinearToolMetadataService: document = await self._resolve_issue(search_space_id, user_id, issue_ref) if not document: return { - "error": f"Issue '{issue_ref}' not found in your indexed Linear issues. " - "This could mean: (1) the issue doesn't exist, (2) it hasn't been indexed yet, " + "error": f"Issue '{issue_ref}' not found in your synced Linear issues. " + "This could mean: (1) the issue doesn't exist, (2) it hasn't been synced yet, " "or (3) the title or identifier is different." } diff --git a/surfsense_backend/app/services/notion/tool_metadata_service.py b/surfsense_backend/app/services/notion/tool_metadata_service.py index c3caed358..d152ee904 100644 --- a/surfsense_backend/app/services/notion/tool_metadata_service.py +++ b/surfsense_backend/app/services/notion/tool_metadata_service.py @@ -1,9 +1,11 @@ +import logging from dataclasses import dataclass from sqlalchemy import and_, func from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.future import select +from app.connectors.notion_history import NotionHistoryConnector from app.db import ( Document, DocumentType, @@ -11,6 +13,8 @@ from app.db import ( SearchSourceConnectorType, ) +logger = logging.getLogger(__name__) + @dataclass class NotionAccount: @@ -83,8 +87,15 @@ class NotionToolMetadataService: search_space_id, accounts ) + 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 + accounts_with_status.append(acc_dict) + return { - "accounts": [acc.to_dict() for acc in accounts], + "accounts": accounts_with_status, "parent_pages": parent_pages, } @@ -109,8 +120,8 @@ class NotionToolMetadataService: if not document: return { - "error": f"Page '{page_title}' not found in your indexed Notion pages. " - "This could mean: (1) the page doesn't exist, (2) it hasn't been indexed yet, " + "error": f"Page '{page_title}' not found in your synced Notion pages. " + "This could mean: (1) the page doesn't exist, (2) it hasn't been synced yet, " "or (3) the page title is different. Please check the exact page title in Notion." } @@ -167,6 +178,26 @@ class NotionToolMetadataService: connectors = result.scalars().all() return [NotionAccount.from_connector(conn) for conn in connectors] + async def _check_account_health(self, connector_id: int) -> bool: + """Check if a Notion connector's token is still valid. + + Uses a lightweight ``users.me()`` call to verify the token. + + Returns True if the token is expired/invalid, False if healthy. + """ + try: + connector = NotionHistoryConnector( + session=self._db_session, connector_id=connector_id + ) + client = await connector._get_client() + await client.users.me() + return False + except Exception as e: + logger.warning( + "Notion connector %s health check failed: %s", connector_id, e + ) + return True + async def _get_parent_pages_by_account( self, search_space_id: int, accounts: list[NotionAccount] ) -> dict: 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 794504b03..d72026d9e 100644 --- a/surfsense_web/components/tool-ui/google-drive/create-file.tsx +++ b/surfsense_web/components/tool-ui/google-drive/create-file.tsx @@ -179,7 +179,7 @@ function ApprovalCard({ return (
{/* Header */} -
+

{decided === "reject" @@ -240,7 +240,7 @@ function ApprovalCard({ {!decided && interruptData.context && ( <>

-
+
{interruptData.context.error ? (

{interruptData.context.error}

) : ( @@ -333,7 +333,7 @@ function ApprovalCard({ {!decided && ( <>
-
+
{allowedDecisions.includes("approve") && ( - -
+
+ + +
)}
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 beddbc74d..fad44b913 100644 --- a/surfsense_web/components/tool-ui/linear/create-linear-issue.tsx +++ b/surfsense_web/components/tool-ui/linear/create-linear-issue.tsx @@ -58,6 +58,7 @@ interface LinearWorkspace { organization_name: string; teams: LinearTeam[]; priorities: LinearPriority[]; + auth_expired?: boolean; } interface InterruptResult { @@ -91,7 +92,14 @@ interface ErrorResult { message: string; } -type CreateLinearIssueResult = InterruptResult | SuccessResult | ErrorResult; +interface AuthErrorResult { + status: "auth_error"; + message: string; + connector_id?: number; + connector_type: string; +} + +type CreateLinearIssueResult = InterruptResult | SuccessResult | ErrorResult | AuthErrorResult; function isInterruptResult(result: unknown): result is InterruptResult { return ( @@ -111,6 +119,15 @@ function isErrorResult(result: unknown): result is ErrorResult { ); } +function isAuthErrorResult(result: unknown): result is AuthErrorResult { + return ( + typeof result === "object" && + result !== null && + "status" in result && + (result as AuthErrorResult).status === "auth_error" + ); +} + function ApprovalCard({ args, interruptData, @@ -138,10 +155,12 @@ function ApprovalCard({ const [selectedLabelIds, setSelectedLabelIds] = useState([]); const workspaces = interruptData.context?.workspaces ?? []; + const validWorkspaces = useMemo(() => workspaces.filter((w) => !w.auth_expired), [workspaces]); + const expiredWorkspaces = useMemo(() => workspaces.filter((w) => w.auth_expired), [workspaces]); const selectedWorkspace = useMemo( - () => workspaces.find((w) => String(w.id) === selectedWorkspaceId) ?? null, - [workspaces, selectedWorkspaceId] + () => validWorkspaces.find((w) => String(w.id) === selectedWorkspaceId) ?? null, + [validWorkspaces, selectedWorkspaceId] ); const selectedTeam = useMemo( @@ -195,7 +214,7 @@ function ApprovalCard({ return (
{/* Header */} -
+

{decided === "reject" @@ -249,40 +268,48 @@ function ApprovalCard({ {!decided && ( <>

-
+
{interruptData.context?.error ? (

{interruptData.context.error}

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

- Linear Account * -

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

+ Linear Account * +

+ +
+ )} {selectedWorkspace && ( <> @@ -443,7 +470,7 @@ function ApprovalCard({ {!decided && ( <>
-
+
{allowedDecisions.includes("approve") && ( - -
+
+ + +
)}
); } +function AuthErrorCard({ result }: { result: AuthErrorResult }) { + return ( +
+
+

+ Notion authentication expired +

+
+
+
+

{result.message}

+
+
+ ); +} + function ErrorCard({ result }: { result: ErrorResult }) { return (
@@ -277,8 +310,13 @@ function ErrorCard({ result }: { result: ErrorResult }) { function InfoCard({ result }: { result: InfoResult }) { return (
-
- +
+

+ Page not found +

+
+
+

{result.message}

@@ -387,6 +425,10 @@ export const DeleteNotionPageToolUI = makeAssistantToolUI< return ; } + if (isAuthErrorResult(result)) { + return ; + } + if (isErrorResult(result)) { return ; } 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 db65151f0..693b80945 100644 --- a/surfsense_web/components/tool-ui/notion/update-notion-page.tsx +++ b/surfsense_web/components/tool-ui/notion/update-notion-page.tsx @@ -1,7 +1,7 @@ "use client"; import { makeAssistantToolUI } from "@assistant-ui/react"; -import { CornerDownLeftIcon, InfoIcon, Pen } from "lucide-react"; +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"; @@ -59,7 +59,14 @@ interface InfoResult { message: string; } -type UpdateNotionPageResult = InterruptResult | SuccessResult | ErrorResult | InfoResult; +interface AuthErrorResult { + status: "auth_error"; + message: string; + connector_id?: number; + connector_type: string; +} + +type UpdateNotionPageResult = InterruptResult | SuccessResult | ErrorResult | InfoResult | AuthErrorResult; function isInterruptResult(result: unknown): result is InterruptResult { return ( @@ -79,6 +86,15 @@ function isErrorResult(result: unknown): result is ErrorResult { ); } +function isAuthErrorResult(result: unknown): result is AuthErrorResult { + return ( + typeof result === "object" && + result !== null && + "status" in result && + (result as AuthErrorResult).status === "auth_error" + ); +} + function isInfoResult(result: unknown): result is InfoResult { return ( typeof result === "object" && @@ -144,7 +160,7 @@ function ApprovalCard({ return (
{/* Header */} -
+

{decided === "reject" @@ -202,7 +218,7 @@ function ApprovalCard({ {!decided && interruptData.context && ( <>

-
+
{interruptData.context.error ? (

{interruptData.context.error}

) : ( @@ -258,7 +274,7 @@ function ApprovalCard({ {!decided && ( <>
-
+
{allowedDecisions.includes("approve") && (