feat: implement re-authentication flow for Linear and Notion HITL connectors and improve their HITL flow and error states

- Added re-authentication endpoints for Linear and Notion connectors to handle expired authentication.
- Enhanced error handling in issue creation, deletion, and update tools to return appropriate authentication error messages.
- Updated UI components to display authentication status and guide users on re-authentication steps.
- Improved account health checks to ensure valid tokens are used for operations.
This commit is contained in:
Anish Sarkar 2026-03-18 14:10:11 +05:30
parent 5fb33b7cff
commit df872e261e
18 changed files with 724 additions and 155 deletions

View file

@ -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(
{

View file

@ -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}

View file

@ -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}

View file

@ -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(
{

View file

@ -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:

View file

@ -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:

View file

@ -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,

View file

@ -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

View file

@ -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."
}

View file

@ -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:

View file

@ -179,7 +179,7 @@ function ApprovalCard({
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300">
{/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4">
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div>
<p className="text-sm font-semibold text-foreground">
{decided === "reject"
@ -240,7 +240,7 @@ function ApprovalCard({
{!decided && interruptData.context && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-4">
<div className="px-5 py-4 space-y-4 select-none">
{interruptData.context.error ? (
<p className="text-sm text-destructive">{interruptData.context.error}</p>
) : (
@ -333,7 +333,7 @@ function ApprovalCard({
{!decided && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2">
<div className="px-5 py-4 flex items-center gap-2 select-none">
{allowedDecisions.includes("approve") && (
<Button
size="sm"

View file

@ -186,7 +186,7 @@ function ApprovalCard({
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300">
{/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4">
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div>
<p className="text-sm font-semibold text-foreground">
{decided === "reject"
@ -209,7 +209,7 @@ function ApprovalCard({
{!decided && interruptData.context && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-4">
<div className="px-5 py-4 space-y-4 select-none">
{interruptData.context.error ? (
<p className="text-sm text-destructive">{interruptData.context.error}</p>
) : (
@ -254,7 +254,7 @@ function ApprovalCard({
{!decided && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-3">
<div className="px-5 py-4 space-y-3 select-none">
<p className="text-xs text-muted-foreground">
The file will be moved to Google Drive trash. You can restore it from trash within 30 days.
</p>
@ -280,27 +280,27 @@ function ApprovalCard({
{!decided && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2">
<Button
size="sm"
className="rounded-lg gap-1.5"
onClick={handleApprove}
>
Approve
<CornerDownLeftIcon className="size-3 opacity-60" />
</Button>
<Button
size="sm"
variant="ghost"
className="rounded-lg text-muted-foreground"
onClick={() => {
setDecided("reject");
onDecision({ type: "reject", message: "User rejected the action." });
}}
>
Reject
</Button>
</div>
<div className="px-5 py-4 flex items-center gap-2 select-none">
<Button
size="sm"
className="rounded-lg gap-1.5"
onClick={handleApprove}
>
Approve
<CornerDownLeftIcon className="size-3 opacity-60" />
</Button>
<Button
size="sm"
variant="ghost"
className="rounded-lg text-muted-foreground"
onClick={() => {
setDecided("reject");
onDecision({ type: "reject", message: "User rejected the action." });
}}
>
Reject
</Button>
</div>
</>
)}
</div>

View file

@ -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<string[]>([]);
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 (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300">
{/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4">
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div>
<p className="text-sm font-semibold text-foreground">
{decided === "reject"
@ -249,40 +268,48 @@ function ApprovalCard({
{!decided && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-4">
<div className="px-5 py-4 space-y-4 select-none">
{interruptData.context?.error ? (
<p className="text-sm text-destructive">{interruptData.context.error}</p>
) : (
<>
{workspaces.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
Linear Account <span className="text-destructive">*</span>
</p>
<Select
value={selectedWorkspaceId}
onValueChange={(v) => {
setSelectedWorkspaceId(v);
setSelectedTeamId("");
setSelectedStateId("__none__");
setSelectedAssigneeId("__none__");
setSelectedPriority("0");
setSelectedLabelIds([]);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select an account" />
</SelectTrigger>
<SelectContent>
{workspaces.map((w) => (
<SelectItem key={w.id} value={String(w.id)}>
{w.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{workspaces.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
Linear Account <span className="text-destructive">*</span>
</p>
<Select
value={selectedWorkspaceId}
onValueChange={(v) => {
setSelectedWorkspaceId(v);
setSelectedTeamId("");
setSelectedStateId("__none__");
setSelectedAssigneeId("__none__");
setSelectedPriority("0");
setSelectedLabelIds([]);
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select an account" />
</SelectTrigger>
<SelectContent>
{validWorkspaces.map((w) => (
<SelectItem key={w.id} value={String(w.id)}>
{w.name}
</SelectItem>
))}
{expiredWorkspaces.map((w) => (
<div
key={w.id}
className="relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 px-2 text-sm select-none opacity-50 pointer-events-none"
>
{w.name} (needs re-authentication)
</div>
))}
</SelectContent>
</Select>
</div>
)}
{selectedWorkspace && (
<>
@ -443,7 +470,7 @@ function ApprovalCard({
{!decided && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2">
<div className="px-5 py-4 flex items-center gap-2 select-none">
{allowedDecisions.includes("approve") && (
<Button
size="sm"
@ -475,6 +502,22 @@ function ApprovalCard({
);
}
function AuthErrorCard({ result }: { result: AuthErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">
Linear authentication expired
</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
);
}
function ErrorCard({ result }: { result: ErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30">
@ -560,6 +603,7 @@ export const CreateLinearIssueToolUI = makeAssistantToolUI<
return null;
}
if (isAuthErrorResult(result)) return <AuthErrorCard result={result} />;
if (isErrorResult(result)) return <ErrorCard result={result} />;
return <SuccessCard result={result as SuccessResult} />;

View file

@ -1,7 +1,7 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import { CornerDownLeftIcon, InfoIcon, TriangleAlertIcon } from "lucide-react";
import { CornerDownLeftIcon, TriangleAlertIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
@ -54,12 +54,20 @@ interface WarningResult {
message?: string;
}
interface AuthErrorResult {
status: "auth_error";
message: string;
connector_id?: number;
connector_type: string;
}
type DeleteLinearIssueResult =
| InterruptResult
| SuccessResult
| ErrorResult
| NotFoundResult
| WarningResult;
| WarningResult
| AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
@ -99,6 +107,15 @@ function isWarningResult(result: unknown): result is WarningResult {
);
}
function isAuthErrorResult(result: unknown): result is AuthErrorResult {
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as AuthErrorResult).status === "auth_error"
);
}
function ApprovalCard({
interruptData,
onDecision,
@ -150,7 +167,7 @@ function ApprovalCard({
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300">
{/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4">
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div>
<p className="text-sm font-semibold text-foreground">
{decided === "reject"
@ -173,7 +190,7 @@ function ApprovalCard({
{!decided && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-4">
<div className="px-5 py-4 space-y-4 select-none">
{context?.error ? (
<p className="text-sm text-destructive">{context.error}</p>
) : (
@ -210,7 +227,7 @@ function ApprovalCard({
{!decided && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
<div className="px-5 py-4 select-none">
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
@ -233,11 +250,11 @@ function ApprovalCard({
{!decided && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2">
<Button
size="sm"
className="rounded-lg gap-1.5"
onClick={handleApprove}
<div className="px-5 py-4 flex items-center gap-2 select-none">
<Button
size="sm"
className="rounded-lg gap-1.5"
onClick={handleApprove}
>
Approve
<CornerDownLeftIcon className="size-3 opacity-60" />
@ -260,6 +277,22 @@ function ApprovalCard({
);
}
function AuthErrorCard({ result }: { result: AuthErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">
Linear authentication expired
</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
);
}
function ErrorCard({ result }: { result: ErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30">
@ -277,8 +310,13 @@ function ErrorCard({ result }: { result: ErrorResult }) {
function NotFoundCard({ result }: { result: NotFoundResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30">
<div className="flex items-start gap-3 px-5 py-4">
<InfoIcon className="size-4 mt-0.5 shrink-0 text-muted-foreground" />
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-amber-600 dark:text-amber-400">
Issue not found
</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
@ -361,6 +399,7 @@ export const DeleteLinearIssueToolUI = makeAssistantToolUI<
}
if (isNotFoundResult(result)) return <NotFoundCard result={result} />;
if (isAuthErrorResult(result)) return <AuthErrorCard result={result} />;
if (isWarningResult(result)) return <WarningCard result={result} />;
if (isErrorResult(result)) return <ErrorCard result={result} />;

View file

@ -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 {
@ -99,7 +99,14 @@ interface NotFoundResult {
message: string;
}
type UpdateLinearIssueResult = InterruptResult | SuccessResult | ErrorResult | NotFoundResult;
interface AuthErrorResult {
status: "auth_error";
message: string;
connector_id?: number;
connector_type: string;
}
type UpdateLinearIssueResult = InterruptResult | SuccessResult | ErrorResult | NotFoundResult | AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
@ -128,6 +135,15 @@ function isNotFoundResult(result: unknown): result is NotFoundResult {
);
}
function isAuthErrorResult(result: unknown): result is AuthErrorResult {
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as AuthErrorResult).status === "auth_error"
);
}
function ApprovalCard({
interruptData,
onDecision,
@ -259,7 +275,7 @@ function ApprovalCard({
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300">
{/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4">
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div>
<p className="text-sm font-semibold text-foreground">
{decided === "reject"
@ -322,7 +338,7 @@ function ApprovalCard({
{!decided && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-4">
<div className="px-5 py-4 space-y-4 select-none">
{context?.error ? (
<p className="text-sm text-destructive">{context.error}</p>
) : (
@ -569,7 +585,7 @@ function ApprovalCard({
{!decided && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2">
<div className="px-5 py-4 flex items-center gap-2 select-none">
{allowedDecisions.includes("approve") && (
<Button
size="sm"
@ -600,6 +616,22 @@ function ApprovalCard({
);
}
function AuthErrorCard({ result }: { result: AuthErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">
Linear authentication expired
</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
);
}
function ErrorCard({ result }: { result: ErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30">
@ -617,8 +649,13 @@ function ErrorCard({ result }: { result: ErrorResult }) {
function NotFoundCard({ result }: { result: NotFoundResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30">
<div className="flex items-start gap-3 px-5 py-4">
<InfoIcon className="size-4 mt-0.5 shrink-0 text-muted-foreground" />
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-amber-600 dark:text-amber-400">
Issue not found
</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
@ -704,6 +741,7 @@ export const UpdateLinearIssueToolUI = makeAssistantToolUI<
}
if (isNotFoundResult(result)) return <NotFoundCard result={result} />;
if (isAuthErrorResult(result)) return <AuthErrorCard result={result} />;
if (isErrorResult(result)) return <ErrorCard result={result} />;
return <SuccessCard result={result as SuccessResult} />;

View file

@ -37,6 +37,7 @@ interface InterruptResult {
workspace_id: string | null;
workspace_name: string;
workspace_icon: string;
auth_expired?: boolean;
}>;
parent_pages?: Record<
number,
@ -65,7 +66,14 @@ interface ErrorResult {
message: string;
}
type CreateNotionPageResult = InterruptResult | SuccessResult | ErrorResult;
interface AuthErrorResult {
status: "auth_error";
message: string;
connector_id?: number;
connector_type: string;
}
type CreateNotionPageResult = InterruptResult | SuccessResult | ErrorResult | AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
@ -76,6 +84,15 @@ function isInterruptResult(result: unknown): result is InterruptResult {
);
}
function isAuthErrorResult(result: unknown): result is AuthErrorResult {
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as AuthErrorResult).status === "auth_error"
);
}
function isErrorResult(result: unknown): result is ErrorResult {
return (
typeof result === "object" &&
@ -105,13 +122,15 @@ 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 parentPages = interruptData.context?.parent_pages ?? {};
const defaultAccountId = useMemo(() => {
if (args.connector_id) return String(args.connector_id);
if (accounts.length === 1) return String(accounts[0].id);
if (validAccounts.length === 1) return String(validAccounts[0].id);
return "";
}, [args.connector_id, accounts]);
}, [args.connector_id, validAccounts]);
const [selectedAccountId, setSelectedAccountId] = useState<string>(defaultAccountId);
const [selectedParentPageId, setSelectedParentPageId] = useState<string>(
@ -164,7 +183,7 @@ function ApprovalCard({
className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300"
>
{/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4">
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div>
<p className="text-sm font-semibold text-foreground">
{decided === "reject"
@ -225,36 +244,44 @@ function ApprovalCard({
{!decided && interruptData.context && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-4">
<div className="px-5 py-4 space-y-4 select-none">
{interruptData.context.error ? (
<p className="text-sm text-destructive">{interruptData.context.error}</p>
) : (
<>
{accounts.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
Notion Account <span className="text-destructive">*</span>
</p>
<Select
value={selectedAccountId}
onValueChange={(value) => {
setSelectedAccountId(value);
setSelectedParentPageId("__none__");
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select an account" />
</SelectTrigger>
<SelectContent>
{accounts.map((account) => (
<SelectItem key={account.id} value={String(account.id)}>
{account.workspace_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
{accounts.length > 0 && (
<div className="space-y-2">
<p className="text-xs font-medium text-muted-foreground">
Notion Account <span className="text-destructive">*</span>
</p>
<Select
value={selectedAccountId}
onValueChange={(value) => {
setSelectedAccountId(value);
setSelectedParentPageId("__none__");
}}
>
<SelectTrigger className="w-full">
<SelectValue placeholder="Select an account" />
</SelectTrigger>
<SelectContent>
{validAccounts.map((account) => (
<SelectItem key={account.id} value={String(account.id)}>
{account.workspace_name}
</SelectItem>
))}
{expiredAccounts.map((a) => (
<div
key={a.id}
className="relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 px-2 text-sm select-none opacity-50 pointer-events-none"
>
{a.workspace_name} (needs re-authentication)
</div>
))}
</SelectContent>
</Select>
</div>
)}
{selectedAccountId && (
<div className="space-y-2">
@ -316,7 +343,7 @@ function ApprovalCard({
{!decided && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2">
<div className="px-5 py-4 flex items-center gap-2 select-none">
{allowedDecisions.includes("approve") && (
<Button
size="sm"
@ -348,6 +375,22 @@ function ApprovalCard({
);
}
function AuthErrorCard({ result }: { result: AuthErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">
Notion authentication expired
</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
);
}
function ErrorCard({ result }: { result: ErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30">
@ -427,6 +470,10 @@ export const CreateNotionPageToolUI = makeAssistantToolUI<
);
}
if (isAuthErrorResult(result)) {
return <AuthErrorCard result={result} />;
}
if (
typeof result === "object" &&
result !== null &&

View file

@ -1,7 +1,7 @@
"use client";
import { makeAssistantToolUI } from "@assistant-ui/react";
import { CornerDownLeftIcon, InfoIcon, TriangleAlertIcon } from "lucide-react";
import { CornerDownLeftIcon, TriangleAlertIcon } from "lucide-react";
import { useCallback, useEffect, useState } from "react";
import { Button } from "@/components/ui/button";
import { Spinner } from "@/components/ui/spinner";
@ -62,12 +62,20 @@ interface WarningResult {
message?: string;
}
interface AuthErrorResult {
status: "auth_error";
message: string;
connector_id?: number;
connector_type: string;
}
type DeleteNotionPageResult =
| InterruptResult
| SuccessResult
| ErrorResult
| InfoResult
| WarningResult;
| WarningResult
| AuthErrorResult;
function isInterruptResult(result: unknown): result is InterruptResult {
return (
@ -96,6 +104,15 @@ function isInfoResult(result: unknown): result is InfoResult {
);
}
function isAuthErrorResult(result: unknown): result is AuthErrorResult {
return (
typeof result === "object" &&
result !== null &&
"status" in result &&
(result as AuthErrorResult).status === "auth_error"
);
}
function isWarningResult(result: unknown): result is WarningResult {
return (
typeof result === "object" &&
@ -155,7 +172,7 @@ function ApprovalCard({
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300">
{/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4">
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div>
<p className="text-sm font-semibold text-foreground">
{decided === "reject"
@ -178,7 +195,7 @@ function ApprovalCard({
{!decided && interruptData.context && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-4">
<div className="px-5 py-4 space-y-4 select-none">
{interruptData.context.error ? (
<p className="text-sm text-destructive">{interruptData.context.error}</p>
) : (
@ -210,7 +227,7 @@ function ApprovalCard({
{!decided && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
<div className="px-5 py-4 select-none">
<label className="flex items-start gap-2 cursor-pointer">
<input
type="checkbox"
@ -233,33 +250,49 @@ function ApprovalCard({
{!decided && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2">
<Button
size="sm"
className="rounded-lg gap-1.5"
onClick={handleApprove}
>
Approve
<CornerDownLeftIcon className="size-3 opacity-60" />
</Button>
<Button
size="sm"
variant="ghost"
className="rounded-lg text-muted-foreground"
onClick={() => {
setDecided("reject");
onDecision({ type: "reject", message: "User rejected the action." });
}}
>
Reject
</Button>
</div>
<div className="px-5 py-4 flex items-center gap-2 select-none">
<Button
size="sm"
className="rounded-lg gap-1.5"
onClick={handleApprove}
>
Approve
<CornerDownLeftIcon className="size-3 opacity-60" />
</Button>
<Button
size="sm"
variant="ghost"
className="rounded-lg text-muted-foreground"
onClick={() => {
setDecided("reject");
onDecision({ type: "reject", message: "User rejected the action." });
}}
>
Reject
</Button>
</div>
</>
)}
</div>
);
}
function AuthErrorCard({ result }: { result: AuthErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">
Notion authentication expired
</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
);
}
function ErrorCard({ result }: { result: ErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30">
@ -277,8 +310,13 @@ function ErrorCard({ result }: { result: ErrorResult }) {
function InfoCard({ result }: { result: InfoResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30">
<div className="flex items-start gap-3 px-5 py-4">
<InfoIcon className="size-4 mt-0.5 shrink-0 text-muted-foreground" />
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-amber-600 dark:text-amber-400">
Page not found
</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
@ -387,6 +425,10 @@ export const DeleteNotionPageToolUI = makeAssistantToolUI<
return <WarningCard result={result} />;
}
if (isAuthErrorResult(result)) {
return <AuthErrorCard result={result} />;
}
if (isErrorResult(result)) {
return <ErrorCard result={result} />;
}

View file

@ -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 (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30 transition-all duration-300">
{/* Header */}
<div className="flex items-start justify-between px-5 pt-5 pb-4">
<div className="flex items-start justify-between px-5 pt-5 pb-4 select-none">
<div>
<p className="text-sm font-semibold text-foreground">
{decided === "reject"
@ -202,7 +218,7 @@ function ApprovalCard({
{!decided && interruptData.context && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 space-y-4">
<div className="px-5 py-4 space-y-4 select-none">
{interruptData.context.error ? (
<p className="text-sm text-destructive">{interruptData.context.error}</p>
) : (
@ -258,7 +274,7 @@ function ApprovalCard({
{!decided && (
<>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4 flex items-center gap-2">
<div className="px-5 py-4 flex items-center gap-2 select-none">
{allowedDecisions.includes("approve") && (
<Button
size="sm"
@ -289,6 +305,22 @@ function ApprovalCard({
);
}
function AuthErrorCard({ result }: { result: AuthErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30">
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-destructive">
Notion authentication expired
</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
);
}
function ErrorCard({ result }: { result: ErrorResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30">
@ -306,8 +338,13 @@ function ErrorCard({ result }: { result: ErrorResult }) {
function InfoCard({ result }: { result: InfoResult }) {
return (
<div className="my-4 max-w-lg overflow-hidden rounded-2xl border bg-muted/30">
<div className="flex items-start gap-3 px-5 py-4">
<InfoIcon className="size-4 mt-0.5 shrink-0 text-muted-foreground" />
<div className="px-5 pt-5 pb-4">
<p className="text-sm font-semibold text-amber-600 dark:text-amber-400">
Page not found
</p>
</div>
<div className="mx-5 h-px bg-border/50" />
<div className="px-5 py-4">
<p className="text-sm text-muted-foreground">{result.message}</p>
</div>
</div>
@ -392,6 +429,10 @@ export const UpdateNotionPageToolUI = makeAssistantToolUI<
return <InfoCard result={result} />;
}
if (isAuthErrorResult(result)) {
return <AuthErrorCard result={result} />;
}
if (isErrorResult(result)) {
return <ErrorCard result={result} />;
}