This commit is contained in:
Sun Tao 2025-10-16 00:48:00 +08:00
parent eca8110452
commit dffd615018
7 changed files with 656 additions and 162 deletions

View file

@ -2,6 +2,7 @@ from fastapi import APIRouter, HTTPException
from loguru import logger
from app.utils.toolkit.notion_mcp_toolkit import NotionMCPToolkit
from app.utils.toolkit.google_calendar_toolkit import GoogleCalendarToolkit
from app.utils.oauth_state_manager import oauth_state_manager
router = APIRouter(tags=["task"])
@ -60,20 +61,34 @@ async def install_tool(tool: str):
)
elif tool == "google_calendar":
try:
# Use a dummy task_id for installation, as this is just for pre-authentication
toolkit = GoogleCalendarToolkit("install_auth")
# Get available tools to verify connection
tools = [tool_func.func.__name__ for tool_func in toolkit.get_tools()]
logger.info(f"Successfully pre-instantiated {tool} toolkit with {len(tools)} tools")
return {
"success": True,
"tools": tools,
"message": f"Successfully installed {tool} toolkit",
"count": len(tools),
"toolkit_name": "GoogleCalendarToolkit"
}
# Try to initialize toolkit - will succeed if credentials exist
try:
toolkit = GoogleCalendarToolkit("install_auth")
tools = [tool_func.func.__name__ for tool_func in toolkit.get_tools()]
logger.info(f"Successfully initialized Google Calendar toolkit with {len(tools)} tools")
return {
"success": True,
"tools": tools,
"message": f"Successfully installed {tool} toolkit",
"count": len(tools),
"toolkit_name": "GoogleCalendarToolkit"
}
except ValueError as auth_error:
# No credentials - need authorization
logger.info(f"No credentials found, starting authorization: {auth_error}")
# Start background authorization in a new thread
logger.info("Starting background Google Calendar authorization")
GoogleCalendarToolkit.start_background_auth("install_auth")
return {
"success": False,
"status": "authorizing",
"message": "Authorization required. Browser should open automatically. Complete authorization and try installing again.",
"toolkit_name": "GoogleCalendarToolkit",
"requires_auth": True
}
except Exception as e:
logger.error(f"Failed to install {tool} toolkit: {e}")
raise HTTPException(
@ -113,3 +128,61 @@ async def list_available_tools():
}
]
}
@router.get("/oauth/status/{provider}", name="get oauth status")
async def get_oauth_status(provider: str):
"""
Get the current OAuth authorization status for a provider
Args:
provider: OAuth provider name (e.g., 'google_calendar')
Returns:
Current authorization status
"""
state = oauth_state_manager.get_state(provider)
if not state:
return {
"provider": provider,
"status": "not_started",
"message": "No authorization in progress"
}
return state.to_dict()
@router.post("/oauth/cancel/{provider}", name="cancel oauth")
async def cancel_oauth(provider: str):
"""
Cancel an ongoing OAuth authorization flow
Args:
provider: OAuth provider name (e.g., 'google_calendar')
Returns:
Cancellation result
"""
state = oauth_state_manager.get_state(provider)
if not state:
raise HTTPException(
status_code=404,
detail=f"No authorization found for provider '{provider}'"
)
if state.status not in ["pending", "authorizing"]:
raise HTTPException(
status_code=400,
detail=f"Cannot cancel authorization with status '{state.status}'"
)
state.cancel()
logger.info(f"Cancelled OAuth authorization for {provider}")
return {
"success": True,
"provider": provider,
"message": "Authorization cancelled successfully"
}

View file

@ -0,0 +1,109 @@
"""
OAuth authorization state manager for background authorization flows
"""
import threading
from typing import Dict, Optional, Literal
from datetime import datetime
from loguru import logger
AuthStatus = Literal["pending", "authorizing", "success", "failed", "cancelled"]
class OAuthState:
"""Represents the state of an OAuth authorization flow"""
def __init__(self, provider: str):
self.provider = provider
self.status: AuthStatus = "pending"
self.error: Optional[str] = None
self.thread: Optional[threading.Thread] = None
self.result: Optional[any] = None
self.started_at = datetime.now()
self.completed_at: Optional[datetime] = None
self._cancel_event = threading.Event()
self.server = None # Store the local server instance for forced shutdown
def is_cancelled(self) -> bool:
"""Check if cancellation has been requested"""
return self._cancel_event.is_set()
def cancel(self):
"""Request cancellation of the authorization flow"""
self._cancel_event.set()
self.status = "cancelled"
self.completed_at = datetime.now()
def to_dict(self) -> Dict:
"""Convert state to dictionary for API response"""
return {
"provider": self.provider,
"status": self.status,
"error": self.error,
"started_at": self.started_at.isoformat(),
"completed_at": self.completed_at.isoformat() if self.completed_at else None,
}
class OAuthStateManager:
"""Manager for tracking OAuth authorization flows"""
def __init__(self):
self._states: Dict[str, OAuthState] = {}
self._lock = threading.Lock()
def create_state(self, provider: str) -> OAuthState:
"""Create a new OAuth state for a provider"""
with self._lock:
# Cancel any existing authorization for this provider
if provider in self._states:
old_state = self._states[provider]
if old_state.status in ["pending", "authorizing"]:
old_state.cancel()
logger.info(f"Cancelled previous {provider} authorization")
state = OAuthState(provider)
self._states[provider] = state
return state
def get_state(self, provider: str) -> Optional[OAuthState]:
"""Get the current state for a provider"""
with self._lock:
return self._states.get(provider)
def update_status(
self,
provider: str,
status: AuthStatus,
error: Optional[str] = None,
result: Optional[any] = None
):
"""Update the status of an authorization flow"""
with self._lock:
if provider in self._states:
state = self._states[provider]
state.status = status
state.error = error
state.result = result
if status in ["success", "failed", "cancelled"]:
state.completed_at = datetime.now()
logger.info(f"Updated {provider} OAuth status to {status}")
def cleanup_old_states(self, max_age_seconds: int = 3600):
"""Clean up old completed states"""
with self._lock:
now = datetime.now()
to_remove = []
for provider, state in self._states.items():
if state.completed_at:
age = (now - state.completed_at).total_seconds()
if age > max_age_seconds:
to_remove.append(provider)
for provider in to_remove:
del self._states[provider]
logger.debug(f"Cleaned up old OAuth state for {provider}")
# Global instance
oauth_state_manager = OAuthStateManager()

View file

@ -1,11 +1,14 @@
from typing import Any, Dict, List
import os
import threading
from app.component.environment import env
from app.service.task import Agents
from app.utils.listen.toolkit_listen import listen_toolkit
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
from app.utils.oauth_state_manager import oauth_state_manager
from camel.toolkits import GoogleCalendarToolkit as BaseGoogleCalendarToolkit
from loguru import logger
SCOPES = ['https://www.googleapis.com/auth/calendar']
@ -99,52 +102,188 @@ class GoogleCalendarToolkit(BaseGoogleCalendarToolkit, AbstractToolkit):
from google_auth_oauthlib.flow import InstalledAppFlow
from google.auth.transport.requests import Request
client_id = os.environ.get("GOOGLE_CLIENT_ID")
client_secret = os.environ.get("GOOGLE_CLIENT_SECRET")
refresh_token = os.environ.get("GOOGLE_REFRESH_TOKEN")
token_uri = os.environ.get("GOOGLE_TOKEN_URI", "https://oauth2.googleapis.com/token")
creds = None
# First, try to load from token file
try:
if os.path.exists(self._token_path):
logger.info(f"Loading credentials from token file: {self._token_path}")
creds = Credentials.from_authorized_user_file(self._token_path, SCOPES)
except Exception:
logger.info("Successfully loaded credentials from token file")
except Exception as e:
logger.warning(f"Could not load from token file: {e}")
creds = None
if not creds and refresh_token:
creds = Credentials(
None,
refresh_token=refresh_token,
token_uri=token_uri,
client_id=client_id,
client_secret=client_secret,
scopes=SCOPES,
)
# If no token file, try environment variables
if not creds:
client_config = {
"installed": {
"client_id": client_id,
"client_secret": client_secret,
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": token_uri,
"redirect_uris": ["http://localhost"],
}
}
flow = InstalledAppFlow.from_client_config(client_config, SCOPES)
creds = flow.run_local_server(port=0)
client_id = os.environ.get("GOOGLE_CLIENT_ID")
client_secret = os.environ.get("GOOGLE_CLIENT_SECRET")
refresh_token = os.environ.get("GOOGLE_REFRESH_TOKEN")
token_uri = os.environ.get("GOOGLE_TOKEN_URI", "https://oauth2.googleapis.com/token")
if refresh_token and client_id and client_secret:
logger.info("Creating credentials from environment variables")
creds = Credentials(
None,
refresh_token=refresh_token,
token_uri=token_uri,
client_id=client_id,
client_secret=client_secret,
scopes=SCOPES,
)
# If still no creds, check background authorization
if not creds:
state = oauth_state_manager.get_state("google_calendar")
if state and state.status == "success" and state.result:
logger.info("Using credentials from background authorization")
creds = state.result
else:
# No credentials available
raise ValueError("No credentials available. Please run authorization first via /api/install/tool/google_calendar")
# Refresh if expired
if creds and creds.expired and creds.refresh_token:
creds.refresh(Request())
try:
logger.info("Token expired, refreshing...")
creds.refresh(Request())
logger.info("Token refreshed successfully")
except Exception as e:
logger.error(f"Failed to refresh token: {e}")
raise ValueError("Failed to refresh expired token. Please re-authorize.")
# Save credentials
try:
os.makedirs(os.path.dirname(self._token_path), exist_ok=True)
with open(self._token_path, "w") as f:
f.write(creds.to_json())
except Exception:
pass
except Exception as e:
logger.warning(f"Could not save credentials: {e}")
return creds
return creds
@staticmethod
def start_background_auth(api_task_id: str = "install_auth") -> str:
"""
Start background OAuth authorization flow with timeout
Returns the status of the authorization
"""
from google_auth_oauthlib.flow import InstalledAppFlow
from wsgiref import simple_server
import socket
# Check if there's an existing authorization and force stop it
old_state = oauth_state_manager.get_state("google_calendar")
if old_state and old_state.status in ["pending", "authorizing"]:
logger.info("Found existing authorization, forcing shutdown...")
old_state.cancel()
# Try to shutdown the old server if it exists
if hasattr(old_state, 'server') and old_state.server:
try:
old_state.server.shutdown()
logger.info("Old server shutdown successfully")
except Exception as e:
logger.warning(f"Could not shutdown old server: {e}")
# Create new state for this authorization
state = oauth_state_manager.create_state("google_calendar")
def auth_flow():
local_server = None
try:
state.status = "authorizing"
oauth_state_manager.update_status("google_calendar", "authorizing")
client_id = os.environ.get("GOOGLE_CLIENT_ID")
client_secret = os.environ.get("GOOGLE_CLIENT_SECRET")
token_uri = os.environ.get("GOOGLE_TOKEN_URI", "https://oauth2.googleapis.com/token")
logger.info(f"Google Calendar auth - client_id present: {bool(client_id)}, client_secret present: {bool(client_secret)}")
if not client_id or not client_secret:
error_msg = "GOOGLE_CLIENT_ID and GOOGLE_CLIENT_SECRET must be set in environment variables"
logger.error(error_msg)
raise ValueError(error_msg)
client_config = {
"installed": {
"client_id": client_id,
"client_secret": client_secret,
"auth_uri": "https://accounts.google.com/o/oauth2/auth",
"token_uri": token_uri,
"redirect_uris": ["http://localhost"],
}
}
flow = InstalledAppFlow.from_client_config(client_config, SCOPES)
# Check for cancellation before starting
if state.is_cancelled():
logger.info("Authorization cancelled before starting")
return
# This will automatically open browser and wait for user authorization
logger.info("=" * 80)
logger.info(f"[Thread {threading.current_thread().name}] Starting local server for Google Calendar authorization")
logger.info("Browser should open automatically in a moment...")
logger.info("=" * 80)
# Run local server - this will block until authorization completes
# Note: Each call uses a random port (port=0), so multiple concurrent attempts won't conflict
try:
creds = flow.run_local_server(
port=0,
authorization_prompt_message="",
success_message="<h1>Authorization successful!</h1><p>You can close this window and return to Eigent.</p>",
open_browser=True
)
logger.info("Authorization flow completed successfully!")
except Exception as server_error:
logger.error(f"Error during run_local_server: {server_error}")
raise
# Check for cancellation after auth
if state.is_cancelled():
logger.info("Authorization cancelled after completion")
return
# Save credentials to token file
token_path = os.path.join(
os.path.expanduser("~"),
".eigent",
"tokens",
"google_calendar",
f"google_calendar_token_{api_task_id}.json",
)
try:
os.makedirs(os.path.dirname(token_path), exist_ok=True)
with open(token_path, "w") as f:
f.write(creds.to_json())
logger.info(f"Saved Google Calendar credentials to {token_path}")
except Exception as e:
logger.warning(f"Could not save credentials: {e}")
# Update state with success
oauth_state_manager.update_status("google_calendar", "success", result=creds)
logger.info("Google Calendar authorization successful!")
except Exception as e:
if state.is_cancelled():
logger.info("Authorization was cancelled")
oauth_state_manager.update_status("google_calendar", "cancelled")
else:
error_msg = str(e)
logger.error(f"Google Calendar authorization failed: {error_msg}")
oauth_state_manager.update_status("google_calendar", "failed", error=error_msg)
finally:
# Clean up server reference
state.server = None
# Start authorization in background thread
thread = threading.Thread(target=auth_flow, daemon=True, name=f"GoogleCalendar-OAuth-{state.started_at.timestamp()}")
state.thread = thread
thread.start()
logger.info("Started background Google Calendar authorization")
return "authorizing"

View file

@ -78,18 +78,29 @@ export default function IntegrationList({
// items or configs change, recalculate installed
useEffect(() => {
// remove duplicates by config_group
const groupSet = new Set<string>();
configs.forEach((c: any) => {
if (c.config_group) groupSet.add(c.config_group.toLowerCase());
});
// construct installed map
// For Google Calendar, check for allowed env keys
// For other integrations, check by config_group
const map: { [key: string]: boolean } = {};
items.forEach((item) => {
if (groupSet.has(item.key.toLowerCase())) {
map[item.key] = true;
if (item.key === "Google Calendar") {
// Only mark installed when refresh token is present (auth completed)
const hasRefreshToken = configs.some(
(c: any) =>
c.config_group?.toLowerCase() === "google calendar" &&
c.config_name === "GOOGLE_REFRESH_TOKEN" &&
c.config_value && String(c.config_value).length > 0
);
map[item.key] = hasRefreshToken;
} else {
// For other integrations, use config_group presence
const hasConfig = configs.some(
(c: any) => c.config_group?.toLowerCase() === item.key.toLowerCase()
);
map[item.key] = hasConfig;
}
});
setInstalled(map);
}, [items, configs]);
@ -100,23 +111,35 @@ export default function IntegrationList({
value: string
) => {
const configPayload = {
config_group: capitalizeFirstLetter(provider),
// Keep exact group name to satisfy backend whitelist
config_group: provider,
config_name: envVarKey,
config_value: value,
};
// Check if config already exists
const existingConfig = configs.find(
(c: any) => c.config_name === envVarKey &&
c.config_group?.toLowerCase() === provider.toLowerCase()
);
// Fetch latest configs to avoid stale state when deciding POST/PUT
let latestConfigs: any[] = Array.isArray(configs) ? configs : [];
try {
const fresh = await proxyFetchGet("/api/configs");
if (Array.isArray(fresh)) latestConfigs = fresh;
} catch {}
// Backend uniqueness is by config_name for a user
let existingConfig = latestConfigs.find((c: any) => c.config_name === envVarKey);
if (existingConfig) {
// Update existing config
await proxyFetchPut(`/api/configs/${existingConfig.id}`, configPayload);
} else {
// Create new config
await proxyFetchPost("/api/configs", configPayload);
const res = await proxyFetchPost("/api/configs", configPayload);
if (res && res.detail && (res.detail as string).toLowerCase().includes("already exists")) {
try {
const again = await proxyFetchGet("/api/configs");
const found = Array.isArray(again) ? again.find((c: any) => c.config_name === envVarKey) : null;
if (found) {
await proxyFetchPut(`/api/configs/${found.id}`, configPayload);
}
} catch {}
}
}
if (window.electronAPI?.envWrite) {
@ -215,6 +238,7 @@ export default function IntegrationList({
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [items, oauth]);
// install/uninstall
const handleInstall = useCallback(
async (item: IntegrationItem) => {
@ -237,21 +261,22 @@ export default function IntegrationList({
return;
}
if (item.key === "Google Calendar") {
let mcp = {
name: "Google Calendar",
key: "Google Calendar",
install_command: {
env: {} as any,
},
id: 14,
};
item.env_vars.map((key) => {
mcp.install_command.env[key] = "";
});
onShowEnvConfig?.(mcp);
return;
}
if (item.key === "Google Calendar") {
// Always prompt env dialog first instead of jumping to authorization
let mcp = {
name: "Google Calendar",
key: "Google Calendar",
install_command: {
env: {} as any,
},
id: 14,
};
item.env_vars.map((key) => {
mcp.install_command.env[key] = "";
});
onShowEnvConfig?.(mcp);
return;
}
if (installed[item.key]) return;
await item.onInstall();
// refresh configs after install to update installed state indicator
@ -333,6 +358,7 @@ export default function IntegrationList({
></MCPEnvDialog>
{items.map((item) => {
const isInstalled = !!installed[item.key];
return (
<div
key={item.key}

View file

@ -97,37 +97,64 @@ const ToolSelect = forwardRef<
throw error;
}
};
} else if (key.toLowerCase() === 'google calendar') {
onInstall = async () => {
try {
const response = await fetchPost("/install/tool/google_calendar");
if (response.success) {
// Save to config to mark as installed
await proxyFetchPost("/api/configs", {
config_group: "Google Calendar",
config_name: "GOOGLE_CLIENT_ID",
config_value: response.toolkit_name || "GoogleCalendarToolkit",
});
console.log("Google Calendar installed successfully");
// After successful installation, add to selected tools
const calendarItem = {
id: 0, // Use 0 for integration items
key: key,
name: key,
description: "Google Calendar integration for managing events and schedules",
toolkit: "google_calendar_toolkit", // Add the toolkit name
isLocal: true
};
addOption(calendarItem, true);
} else {
console.error("Failed to install Google Calendar:", response.error || "Unknown error");
throw new Error(response.error || "Failed to install Google Calendar");
}
} catch (error: any) {
console.error("Failed to install Google Calendar:", error.message);
throw error;
}
};
} else if (key.toLowerCase() === 'google calendar') {
onInstall = async () => {
try {
const response = await fetchPost("/install/tool/google_calendar");
if (response.success) {
// Check if config exists first to avoid 400 error
const existingConfigs = await proxyFetchGet("/api/configs");
const existing = Array.isArray(existingConfigs)
? existingConfigs.find((c: any) =>
c.config_group?.toLowerCase() === "google calendar" &&
c.config_name === "GOOGLE_REFRESH_TOKEN"
)
: null;
const configPayload = {
config_group: "Google Calendar",
config_name: "GOOGLE_REFRESH_TOKEN",
config_value: "exists",
};
if (existing) {
await proxyFetchPut(`/api/configs/${existing.id}`, configPayload);
} else {
await proxyFetchPost("/api/configs", configPayload);
}
console.log("Google Calendar installed successfully");
// After successful installation, add to selected tools
const calendarItem = {
id: 0, // Use 0 for integration items
key: key,
name: key,
description: "Google Calendar integration for managing events and schedules",
toolkit: "google_calendar_toolkit", // Add the toolkit name
isLocal: true
};
addOption(calendarItem, true);
} else if (response.status === "authorizing") {
// Authorization in progress - browser should have opened
console.log("Google Calendar authorization in progress. Please complete in browser.");
console.log(response.message);
// Don't throw error - this is expected behavior
} else {
// Real error
console.error("Failed to install Google Calendar:", response.error || "Unknown error");
throw new Error(response.error || "Failed to install Google Calendar");
}
// Return the response so IntegrationList can check the status
return response;
} catch (error: any) {
// Only throw if it's a real error, not authorization in progress
if (!error.message?.includes("authorization")) {
console.error("Failed to install Google Calendar:", error.message);
throw error;
}
return null; // Return null on error
}
};
} else {
onInstall = () =>
(window.location.href = `${baseURL}/api/oauth/${key.toLowerCase()}/login`);
@ -271,28 +298,55 @@ const ToolSelect = forwardRef<
// Continue anyway to trigger installation
}
// Trigger instantiation for Google Calendar
if (activeMcp.key === "Google Calendar") {
try {
const response = await fetchPost("/install/tool/google_calendar");
// Trigger instantiation for Google Calendar
if (activeMcp.key === "Google Calendar") {
try {
const response = await fetchPost("/install/tool/google_calendar");
if (response.success) {
// Mark as successfully installed by writing refresh token marker
const existingConfigs = await proxyFetchGet("/api/configs");
const existing = Array.isArray(existingConfigs)
? existingConfigs.find((c: any) =>
c.config_group?.toLowerCase() === "google calendar" &&
c.config_name === "GOOGLE_REFRESH_TOKEN"
)
: null;
if (response.success) {
const selectedItem = {
id: activeMcp.id,
key: activeMcp.key,
name: activeMcp.name,
description: "Google Calendar integration for managing events and schedules",
toolkit: "google_calendar_toolkit",
isLocal: true
};
addOption(selectedItem, true);
const configPayload = {
config_group: "Google Calendar",
config_name: "GOOGLE_REFRESH_TOKEN",
config_value: "exists",
};
if (existing) {
await proxyFetchPut(`/api/configs/${existing.id}`, configPayload);
} else {
console.error("Failed to install Google Calendar:", response.error || "Unknown error");
await proxyFetchPost("/api/configs", configPayload);
}
} catch (error: any) {
console.error("Failed to install Google Calendar:", error.message);
// Refresh integrations to update install status
fetchIntegrationsData();
const selectedItem = {
id: activeMcp.id,
key: activeMcp.key,
name: activeMcp.name,
description: "Google Calendar integration for managing events and schedules",
toolkit: "google_calendar_toolkit",
isLocal: true
};
addOption(selectedItem, true);
} else if (response.status === "authorizing") {
// Authorization in progress - browser should have opened
console.log("Google Calendar authorization in progress. Please complete in browser and try installing again.");
} else {
console.error("Failed to install Google Calendar:", response.error || "Unknown error");
}
} catch (error: any) {
console.error("Failed to install Google Calendar:", error.message);
}
}
return;
}
setInstalling((prev) => ({ ...prev, [id]: true }));

View file

@ -1,10 +1,11 @@
import { useState, useEffect, useCallback } from "react";
import {
proxyFetchGet,
proxyFetchDelete,
proxyFetchPost,
proxyFetchPut,
fetchPost,
proxyFetchGet,
proxyFetchDelete,
proxyFetchPost,
proxyFetchPut,
fetchPost,
fetchGet,
} from "@/api/http";
import MCPList from "./components/MCPList";
import MCPConfigDialog from "./components/MCPConfigDialog";
@ -137,22 +138,93 @@ export default function SettingMCP() {
}
};
} else if (key.toLowerCase() === 'google calendar') {
onInstall = async () => {
try {
const response = await fetchPost("/install/tool/google_calendar");
if (response.success) {
toast.success("Google Calendar installed successfully");
// Refresh the integrations list to show the installed state
fetchList();
// Force refresh IntegrationList component
setRefreshKey(prev => prev + 1);
} else {
toast.error(response.error || "Failed to install Google Calendar");
}
} catch (error: any) {
toast.error(error.message || "Failed to install Google Calendar");
onInstall = async () => {
try {
const response = await fetchPost("/install/tool/google_calendar");
if (response.success) {
// Check if config exists first to avoid 400 error
const existingConfigs = await proxyFetchGet("/api/configs");
const existing = Array.isArray(existingConfigs)
? existingConfigs.find((c: any) =>
c.config_group?.toLowerCase() === "google calendar" &&
c.config_name === "GOOGLE_REFRESH_TOKEN"
)
: null;
const configPayload = {
config_group: "Google Calendar",
config_name: "GOOGLE_REFRESH_TOKEN",
config_value: "exists",
};
if (existing) {
await proxyFetchPut(`/api/configs/${existing.id}`, configPayload);
} else {
await proxyFetchPost("/api/configs", configPayload);
}
toast.success("Google Calendar installed successfully");
// Refresh the integrations list to show the installed state
fetchList();
// Force refresh IntegrationList component
setRefreshKey(prev => prev + 1);
} else if (response.status === "authorizing") {
// Authorization in progress - start polling for completion
toast.info("Please complete authorization in your browser...");
// Poll for authorization completion via oauth status endpoint
const pollInterval = setInterval(async () => {
try {
const statusResp = await fetchGet("/oauth/status/google_calendar");
if (statusResp?.status === "success") {
clearInterval(pollInterval);
// Now that auth succeeded, run install again to initialize toolkit
const finalize = await fetchPost("/install/tool/google_calendar");
if (finalize?.success) {
const configs = await proxyFetchGet("/api/configs");
const existing = Array.isArray(configs)
? configs.find((c: any) =>
c.config_group?.toLowerCase() === "google calendar" &&
c.config_name === "GOOGLE_REFRESH_TOKEN"
)
: null;
const payload = {
config_group: "Google Calendar",
config_name: "GOOGLE_REFRESH_TOKEN",
config_value: "exists",
};
if (existing) {
await proxyFetchPut(`/api/configs/${existing.id}`, payload);
} else {
await proxyFetchPost("/api/configs", payload);
}
toast.success("Google Calendar installed successfully");
fetchList();
setRefreshKey((prev) => prev + 1);
}
} else if (statusResp?.status === "failed" || statusResp?.status === "cancelled") {
clearInterval(pollInterval);
const msg = statusResp?.error || (statusResp?.status === "cancelled" ? "Authorization cancelled" : "Authorization failed");
toast.error(msg);
}
// if still authorizing, continue polling
} catch (err) {
console.error("Polling oauth status failed", err);
}
}, 2000);
// Safety timeout
setTimeout(() => clearInterval(pollInterval), 5 * 60 * 1000);
} else {
toast.error(response.error || response.message || "Failed to install Google Calendar");
}
};
} catch (error: any) {
toast.error(error.message || "Failed to install Google Calendar");
}
};
} else {
onInstall = () =>
(window.location.href = `${baseURL}/api/oauth/${key.toLowerCase()}/login`);

View file

@ -77,16 +77,24 @@ export default function IntegrationList({
// items or configs change, recalculate installed
useEffect(() => {
// remove duplicates by config_group
const groupSet = new Set<string>();
configs.forEach((c: any) => {
if (c.config_group) groupSet.add(c.config_group.toLowerCase());
});
// construct installed map
const map: { [key: string]: boolean } = {};
items.forEach((item) => {
if (groupSet.has(item.key.toLowerCase())) {
map[item.key] = true;
if (item.key === "Google Calendar") {
// Only mark installed after refresh token exists
const hasRefreshToken = configs.some(
(c: any) =>
c.config_group?.toLowerCase() === "google calendar" &&
c.config_name === "GOOGLE_REFRESH_TOKEN" &&
c.config_value && String(c.config_value).length > 0
);
map[item.key] = hasRefreshToken;
} else {
// For other integrations, use presence of any config in the group
const hasConfig = configs.some(
(c: any) => c.config_group?.toLowerCase() === item.key.toLowerCase()
);
map[item.key] = hasConfig;
}
});
setInstalled(map);
@ -99,23 +107,36 @@ export default function IntegrationList({
value: string
) => {
const configPayload = {
config_group: capitalizeFirstLetter(provider),
// Use exact group name, do not transform case to avoid whitelist mismatch
config_group: provider,
config_name: envVarKey,
config_value: value,
};
// Check if config already exists
const existingConfig = configs.find(
(c: any) => c.config_name === envVarKey &&
c.config_group?.toLowerCase() === provider.toLowerCase()
);
// Fetch latest configs to avoid stale state when deciding POST/PUT
let latestConfigs: any[] = Array.isArray(configs) ? configs : [];
try {
const fresh = await proxyFetchGet("/api/configs");
if (Array.isArray(fresh)) latestConfigs = fresh;
} catch {}
// Check if config already exists (by name, regardless of group - backend uniqueness is by name)
let existingConfig = latestConfigs.find((c: any) => c.config_name === envVarKey);
if (existingConfig) {
// Update existing config
await proxyFetchPut(`/api/configs/${existingConfig.id}`, configPayload);
} else {
// Create new config
await proxyFetchPost("/api/configs", configPayload);
const res = await proxyFetchPost("/api/configs", configPayload);
// If backend says it already exists (race), switch to PUT
if (res && res.detail && (res.detail as string).toLowerCase().includes("already exists")) {
try {
const again = await proxyFetchGet("/api/configs");
const found = Array.isArray(again) ? again.find((c: any) => c.config_name === envVarKey) : null;
if (found) {
await proxyFetchPut(`/api/configs/${found.id}`, configPayload);
}
} catch {}
}
}
if (window.electronAPI?.envWrite) {