feat: Add LinkedIn OAuth integration with CAMEL-AI toolkit (#1104)

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Wendong-Fan <w3ndong.fan@gmail.com>
Co-authored-by: Wendong-Fan <133094783+Wendong-Fan@users.noreply.github.com>
This commit is contained in:
Dream 2026-01-30 14:05:02 -05:00 committed by GitHub
parent 4b8394d084
commit ebb4ca00ac
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 622 additions and 30 deletions

View file

@ -12,18 +12,35 @@
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
from typing import Optional
from fastapi import APIRouter, HTTPException
from pydantic import BaseModel
from app.utils.toolkit.notion_mcp_toolkit import NotionMCPToolkit
from app.utils.toolkit.google_calendar_toolkit import GoogleCalendarToolkit
from app.utils.toolkit.linkedin_toolkit import LinkedInToolkit
from app.utils.oauth_state_manager import oauth_state_manager
import logging
from camel.toolkits.hybrid_browser_toolkit.hybrid_browser_toolkit_ts import (
HybridBrowserToolkit as BaseHybridBrowserToolkit,
)
from app.utils.cookie_manager import CookieManager
import os
import time
import uuid
class LinkedInTokenRequest(BaseModel):
r"""Request model for saving LinkedIn OAuth token."""
access_token: str
refresh_token: Optional[str] = None
expires_in: Optional[int] = None
scope: Optional[str] = None
logger = logging.getLogger("tool_controller")
router = APIRouter()
@ -118,10 +135,81 @@ async def install_tool(tool: str):
status_code=500,
detail=f"Failed to install {tool}: {str(e)}"
)
elif tool == "linkedin":
try:
# Check if LinkedIn is already authenticated
if LinkedInToolkit.is_authenticated():
# Check if token is expired
if LinkedInToolkit.is_token_expired():
logger.info("LinkedIn token has expired")
return {
"success": False,
"status": "token_expired",
"message": "LinkedIn token has expired. Please re-authenticate via OAuth.",
"toolkit_name": "LinkedInToolkit",
"requires_auth": True,
"oauth_url": "/api/oauth/linkedin/login"
}
try:
toolkit = LinkedInToolkit("install_auth")
tools = [tool_func.func.__name__ for tool_func in toolkit.get_tools()]
# Try to get profile to verify token is valid
profile = toolkit.get_profile_safe()
# Check if token is expiring soon
token_warning = None
if LinkedInToolkit.is_token_expiring_soon():
token_info = LinkedInToolkit.get_token_info()
if token_info and token_info.get("expires_at"):
days_remaining = (token_info["expires_at"] - int(time.time())) // (24 * 60 * 60)
token_warning = f"Token expires in {days_remaining} days. Consider re-authenticating soon."
logger.info(f"Successfully initialized LinkedIn toolkit with {len(tools)} tools")
result = {
"success": True,
"tools": tools,
"message": f"Successfully installed {tool} toolkit",
"count": len(tools),
"toolkit_name": "LinkedInToolkit",
"profile": profile if "error" not in profile else None
}
if token_warning:
result["warning"] = token_warning
return result
except Exception as e:
logger.warning(f"LinkedIn token may be invalid: {e}")
# Token exists but may be expired/invalid
return {
"success": False,
"status": "token_invalid",
"message": "LinkedIn token may be expired or invalid. Please re-authenticate via OAuth.",
"toolkit_name": "LinkedInToolkit",
"requires_auth": True,
"oauth_url": "/api/oauth/linkedin/login"
}
else:
# No credentials - need OAuth authorization
logger.info("No LinkedIn credentials found, OAuth required")
return {
"success": False,
"status": "not_configured",
"message": "LinkedIn OAuth required. Redirect user to OAuth login.",
"toolkit_name": "LinkedInToolkit",
"requires_auth": True,
"oauth_url": "/api/oauth/linkedin/login"
}
except Exception as e:
logger.error(f"Failed to install {tool} toolkit: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to install {tool}: {str(e)}"
)
else:
raise HTTPException(
status_code=404,
detail=f"Tool '{tool}' not found. Available tools: ['notion', 'google_calendar']"
detail=f"Tool '{tool}' not found. Available tools: ['notion', 'google_calendar', 'linkedin']"
)
@ -148,6 +236,14 @@ async def list_available_tools():
"description": "Google Calendar integration for managing events and schedules",
"toolkit_class": "GoogleCalendarToolkit",
"requires_auth": True
},
{
"name": "linkedin",
"display_name": "LinkedIn",
"description": "LinkedIn integration for creating posts, managing profile, and social media automation",
"toolkit_class": "LinkedInToolkit",
"requires_auth": True,
"oauth_url": "/api/oauth/linkedin/login"
}
]
}
@ -309,10 +405,140 @@ async def uninstall_tool(tool: str):
status_code=500,
detail=f"Failed to uninstall {tool}: {str(e)}"
)
elif tool == "linkedin":
try:
# Clear LinkedIn token
success = LinkedInToolkit.clear_token()
if success:
return {
"success": True,
"message": f"Successfully uninstalled {tool} and cleaned up authentication tokens"
}
else:
return {
"success": True,
"message": f"Uninstalled {tool} (no tokens found to clean up)"
}
except Exception as e:
logger.error(f"Failed to uninstall {tool}: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to uninstall {tool}: {str(e)}"
)
else:
raise HTTPException(
status_code=404,
detail=f"Tool '{tool}' not found. Available tools: ['notion', 'google_calendar']"
detail=f"Tool '{tool}' not found. Available tools: ['notion', 'google_calendar', 'linkedin']"
)
@router.post("/linkedin/save-token", name="save linkedin token")
async def save_linkedin_token(token_request: LinkedInTokenRequest):
r"""Save LinkedIn OAuth token after successful authorization.
Args:
token_request: Token data containing access_token and optionally refresh_token
Returns:
Save result with tool information
"""
try:
token_data = token_request.model_dump(exclude_none=True)
# Save the token
success = LinkedInToolkit.save_token(token_data)
if success:
# Verify the token works by initializing toolkit
try:
toolkit = LinkedInToolkit("install_auth")
tools = [tool_func.func.__name__ for tool_func in toolkit.get_tools()]
profile = toolkit.get_profile_safe()
return {
"success": True,
"message": "LinkedIn token saved successfully",
"tools": tools,
"count": len(tools),
"profile": profile if "error" not in profile else None
}
except Exception as e:
logger.warning(f"Token saved but verification failed: {e}")
return {
"success": True,
"message": "LinkedIn token saved (verification pending)",
"warning": str(e)
}
else:
raise HTTPException(
status_code=500,
detail="Failed to save LinkedIn token"
)
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to save LinkedIn token: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to save token: {str(e)}"
)
@router.get("/linkedin/status", name="get linkedin status")
async def get_linkedin_status():
r"""Get current LinkedIn authentication status and token info.
Returns:
Status information including authentication state and token expiry
"""
try:
is_authenticated = LinkedInToolkit.is_authenticated()
if not is_authenticated:
return {
"authenticated": False,
"status": "not_configured",
"message": "LinkedIn not configured. OAuth required.",
"oauth_url": "/api/oauth/linkedin/login"
}
token_info = LinkedInToolkit.get_token_info()
is_expired = LinkedInToolkit.is_token_expired()
is_expiring_soon = LinkedInToolkit.is_token_expiring_soon()
result = {
"authenticated": True,
"status": "expired" if is_expired else ("expiring_soon" if is_expiring_soon else "valid"),
}
if token_info:
if token_info.get("expires_at"):
current_time = int(time.time())
expires_at = token_info["expires_at"]
seconds_remaining = max(0, expires_at - current_time)
days_remaining = seconds_remaining // (24 * 60 * 60)
result["expires_at"] = expires_at
result["days_remaining"] = days_remaining
if token_info.get("saved_at"):
result["saved_at"] = token_info["saved_at"]
if is_expired:
result["message"] = "Token has expired. Please re-authenticate."
result["oauth_url"] = "/api/oauth/linkedin/login"
elif is_expiring_soon:
result["message"] = f"Token expires in {result.get('days_remaining', 'unknown')} days. Consider re-authenticating."
result["oauth_url"] = "/api/oauth/linkedin/login"
else:
result["message"] = "LinkedIn is connected and token is valid."
return result
except Exception as e:
logger.error(f"Failed to get LinkedIn status: {e}")
raise HTTPException(
status_code=500,
detail=f"Failed to get status: {str(e)}"
)

View file

@ -12,6 +12,12 @@
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
import json
import os
import logging
import time
from typing import Optional
from camel.toolkits import LinkedInToolkit as BaseLinkedInToolkit
from camel.toolkits.function_tool import FunctionTool
from app.component.environment import env
@ -19,18 +25,258 @@ from app.service.task import Agents
from app.utils.listen.toolkit_listen import auto_listen_toolkit
from app.utils.toolkit.abstract_toolkit import AbstractToolkit
logger = logging.getLogger("linkedin_toolkit")
# LinkedIn access tokens expire after 60 days (in seconds)
LINKEDIN_TOKEN_LIFETIME_SECONDS = 60 * 24 * 60 * 60
# Refresh token when less than 7 days remaining
LINKEDIN_TOKEN_REFRESH_THRESHOLD_SECONDS = 7 * 24 * 60 * 60
@auto_listen_toolkit(BaseLinkedInToolkit)
class LinkedInToolkit(BaseLinkedInToolkit, AbstractToolkit):
r"""LinkedIn toolkit for social media automation.
This toolkit wraps CAMEL-AI's LinkedInToolkit and provides:
- Post creation and deletion
- Profile information retrieval
- Token file persistence
- Environment variable fallback
"""
agent_name: str = Agents.social_medium_agent
def __init__(self, api_task_id: str, timeout: float | None = None):
super().__init__(timeout)
self.api_task_id = api_task_id
self._token_path = self._build_canonical_token_path()
self._load_credentials()
super().__init__(timeout)
@classmethod
def _build_canonical_token_path(cls) -> str:
r"""Build the canonical path for storing LinkedIn tokens."""
return env("LINKEDIN_TOKEN_PATH") or os.path.join(
os.path.expanduser("~"),
".eigent",
"tokens",
"linkedin",
"linkedin_token.json",
)
def _load_credentials(self):
r"""Load credentials from token file or environment variables."""
from dotenv import load_dotenv
# Force reload environment variables from default .env file
default_env_path = os.path.join(os.path.expanduser("~"), ".eigent", ".env")
if os.path.exists(default_env_path):
load_dotenv(dotenv_path=default_env_path, override=True)
# Try to load from token file first
if os.path.exists(self._token_path):
try:
with open(self._token_path, "r") as f:
token_data = json.load(f)
access_token = token_data.get("access_token")
if access_token:
# Check if token is expired before loading
expires_at = token_data.get("expires_at")
if expires_at and int(time.time()) >= expires_at:
logger.warning("LinkedIn token file contains expired token, skipping load")
else:
os.environ["LINKEDIN_ACCESS_TOKEN"] = access_token
logger.info(f"Loaded LinkedIn credentials from token file: {self._token_path}")
except Exception as e:
logger.warning(f"Could not load LinkedIn token file: {e}")
@classmethod
def get_can_use_tools(cls, api_task_id: str) -> list[FunctionTool]:
r"""Return tools only if LinkedIn credentials are configured."""
from dotenv import load_dotenv
# Force reload environment variables
default_env_path = os.path.join(os.path.expanduser("~"), ".eigent", ".env")
if os.path.exists(default_env_path):
load_dotenv(dotenv_path=default_env_path, override=True)
# Check for token file
token_path = cls._build_canonical_token_path()
if os.path.exists(token_path):
try:
with open(token_path, "r") as f:
token_data = json.load(f)
access_token = token_data.get("access_token")
if access_token:
# Check if token is expired before loading
expires_at = token_data.get("expires_at")
if expires_at and int(time.time()) >= expires_at:
logger.warning("LinkedIn token file contains expired token, skipping load")
else:
os.environ["LINKEDIN_ACCESS_TOKEN"] = access_token
except Exception:
pass
if env("LINKEDIN_ACCESS_TOKEN"):
return LinkedInToolkit(api_task_id).get_tools()
else:
return []
@classmethod
def save_token(cls, token_data: dict) -> bool:
r"""Save OAuth token to file.
Args:
token_data: Dictionary containing access_token and optionally refresh_token
Returns:
True if saved successfully, False otherwise
"""
token_path = cls._build_canonical_token_path()
try:
# Add timestamp for expiration tracking if not present
if "saved_at" not in token_data:
token_data["saved_at"] = int(time.time())
# Calculate expiration time if expires_in is provided
if "expires_in" in token_data and "expires_at" not in token_data:
token_data["expires_at"] = token_data["saved_at"] + token_data["expires_in"]
elif "expires_at" not in token_data:
# Default to 60 days if no expiration info provided
token_data["expires_at"] = token_data["saved_at"] + LINKEDIN_TOKEN_LIFETIME_SECONDS
os.makedirs(os.path.dirname(token_path), exist_ok=True)
with open(token_path, "w") as f:
json.dump(token_data, f, indent=2)
logger.info(f"Saved LinkedIn token to {token_path}")
# Also update environment variable
if token_data.get("access_token"):
os.environ["LINKEDIN_ACCESS_TOKEN"] = token_data["access_token"]
return True
except Exception as e:
logger.error(f"Failed to save LinkedIn token: {e}")
return False
@classmethod
def clear_token(cls) -> bool:
r"""Remove stored token file and clear environment variable.
Returns:
True if cleared successfully, False otherwise
"""
token_path = cls._build_canonical_token_path()
try:
if os.path.exists(token_path):
os.remove(token_path)
logger.info(f"Removed LinkedIn token file: {token_path}")
# Also try to remove the parent directory if empty
token_dir = os.path.dirname(token_path)
if os.path.exists(token_dir) and not os.listdir(token_dir):
os.rmdir(token_dir)
logger.info(f"Removed empty LinkedIn token directory: {token_dir}")
# Clear environment variable
if "LINKEDIN_ACCESS_TOKEN" in os.environ:
del os.environ["LINKEDIN_ACCESS_TOKEN"]
return True
except Exception as e:
logger.error(f"Failed to clear LinkedIn token: {e}")
return False
@classmethod
def is_authenticated(cls) -> bool:
r"""Check if user has valid LinkedIn credentials.
Returns:
True if credentials are available, False otherwise
"""
from dotenv import load_dotenv
# Force reload environment variables
default_env_path = os.path.join(os.path.expanduser("~"), ".eigent", ".env")
if os.path.exists(default_env_path):
load_dotenv(dotenv_path=default_env_path, override=True)
# Check token file
token_path = cls._build_canonical_token_path()
if os.path.exists(token_path):
try:
with open(token_path, "r") as f:
token_data = json.load(f)
if token_data.get("access_token"):
return True
except Exception:
pass
# Check environment variable
return bool(env("LINKEDIN_ACCESS_TOKEN"))
@classmethod
def get_token_info(cls) -> Optional[dict]:
r"""Get stored token information including expiration.
Returns:
Token data dictionary or None if not available
"""
token_path = cls._build_canonical_token_path()
if os.path.exists(token_path):
try:
with open(token_path, "r") as f:
return json.load(f)
except Exception:
pass
return None
@classmethod
def is_token_expiring_soon(cls) -> bool:
r"""Check if token is expiring within the refresh threshold.
Returns:
True if token is expiring soon or already expired, False otherwise.
Returns False if no token file exists (e.g. env var auth only).
"""
token_info = cls.get_token_info()
if not token_info:
return False # No token file; cannot determine expiry, assume valid
expires_at = token_info.get("expires_at")
if not expires_at:
return False # No expiration info, assume valid
current_time = int(time.time())
time_remaining = expires_at - current_time
return time_remaining < LINKEDIN_TOKEN_REFRESH_THRESHOLD_SECONDS
@classmethod
def is_token_expired(cls) -> bool:
r"""Check if token has expired.
Returns:
True if token is expired, False otherwise.
Returns False if no token file exists (e.g. env var auth only).
"""
token_info = cls.get_token_info()
if not token_info:
return False # No token file; cannot determine expiry, assume valid
expires_at = token_info.get("expires_at")
if not expires_at:
return False # No expiration info, assume valid
return int(time.time()) >= expires_at
def get_profile_safe(self) -> dict:
r"""Get LinkedIn profile with error handling.
Returns:
Profile dictionary or error information
"""
try:
return self.get_profile(include_id=True)
except Exception as e:
logger.error(f"Failed to get LinkedIn profile: {e}")
return {"error": str(e)}

View file

@ -1,19 +1,20 @@
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
# ========= Copyright 2025-2026 @ Eigent.ai All Rights Reserved. =========
from abc import ABC, abstractmethod
from typing import Dict, Any, Optional
from urllib.parse import urlencode
import os
import httpx
from pydantic import BaseModel
@ -191,6 +192,60 @@ class EXAOAuthAdapter(OAuthAdapter):
return None
class LinkedInOAuthAdapter(OAuthAdapter):
r"""LinkedIn OAuth 2.0 adapter for 3-legged OAuth flow."""
AUTHORIZATION_URL = "https://www.linkedin.com/oauth/v2/authorization"
TOKEN_URL = "https://www.linkedin.com/oauth/v2/accessToken"
SCOPES = "openid profile email w_member_social"
def __init__(self, redirect_uri: Optional[str] = None):
self.client_id = env("LINKEDIN_CLIENT_ID", "")
self.client_secret = env("LINKEDIN_CLIENT_SECRET", "")
self.redirect_uri = redirect_uri or env(
"LINKEDIN_REDIRECT_URI", "https://localhost/api/oauth/linkedin/callback"
)
def get_authorize_url(self, state: Optional[str] = None) -> Optional[str]:
params = {
"response_type": "code",
"client_id": self.client_id,
"redirect_uri": self.redirect_uri,
"scope": self.SCOPES,
}
if state:
params["state"] = state
return f"{self.AUTHORIZATION_URL}?{urlencode(params)}"
def fetch_token(self, code: Optional[str]) -> Optional[Dict[str, Any]]:
if not code:
return None
headers = {"Content-Type": "application/x-www-form-urlencoded"}
data = {
"grant_type": "authorization_code",
"code": code,
"client_id": self.client_id,
"client_secret": self.client_secret,
"redirect_uri": self.redirect_uri,
}
with httpx.Client() as client:
resp = client.post(self.TOKEN_URL, headers=headers, data=data)
return resp.json()
def refresh_token(self, refresh_token: str) -> Optional[Dict[str, Any]]:
r"""Refresh an expired access token."""
headers = {"Content-Type": "application/x-www-form-urlencoded"}
data = {
"grant_type": "refresh_token",
"refresh_token": refresh_token,
"client_id": self.client_id,
"client_secret": self.client_secret,
}
with httpx.Client() as client:
resp = client.post(self.TOKEN_URL, headers=headers, data=data)
return resp.json()
# 工厂方法
OAUTH_ADAPTERS = {
"slack": SlackOAuthAdapter,
@ -198,20 +253,16 @@ OAUTH_ADAPTERS = {
"x": XOAuthAdapter,
"twitter": XOAuthAdapter,
"googlesuite": GoogleSuiteOAuthAdapter,
"linkedin": LinkedInOAuthAdapter,
}
def get_oauth_adapter(app_name: str, redirect_uri: Optional[str] = None) -> OAuthAdapter:
adapter_cls = OAUTH_ADAPTERS.get(app_name.lower())
if not adapter_cls:
raise ValueError(f"不支持的OAuth应用: {app_name}")
if app_name.lower() == "slack":
return adapter_cls(redirect_uri=redirect_uri)
if app_name.lower() == "notion":
return adapter_cls(redirect_uri=redirect_uri)
if app_name.lower() == "x" or app_name.lower() == "twitter":
return adapter_cls(redirect_uri=redirect_uri)
return adapter_cls()
raise ValueError(f"Unsupported OAuth application: {app_name}")
# All adapters support redirect_uri parameter
return adapter_cls(redirect_uri=redirect_uri)
class OauthCallbackPayload(BaseModel):

View file

@ -79,7 +79,12 @@ class ConfigInfo:
"toolkit": "whatsapp_toolkit",
},
ConfigGroup.LINKEDIN.value: {
"env_vars": ["LINKEDIN_ACCESS_TOKEN"],
"env_vars": [
"LINKEDIN_CLIENT_ID",
"LINKEDIN_CLIENT_SECRET",
"LINKEDIN_ACCESS_TOKEN",
"LINKEDIN_REFRESH_TOKEN",
],
"toolkit": "linkedin_toolkit",
},
ConfigGroup.REDDIT.value: {

View file

@ -38,6 +38,7 @@ import {
useIntegrationManagement,
type IntegrationItem,
} from "@/hooks/useIntegrationManagement";
import { getProxyBaseURL } from "@/lib";
type IntegrationListVariant = "select" | "manage";
@ -124,6 +125,15 @@ export default function IntegrationList({
return;
}
// LinkedIn uses server-side OAuth flow
if (item.key === "LinkedIn") {
// Open LinkedIn OAuth login via the remote server (same pattern as other OAuth providers)
const baseUrl = getProxyBaseURL();
const oauthUrl = `${baseUrl}/api/oauth/linkedin/login`;
window.open(oauthUrl, "_blank", "width=600,height=700");
return;
}
if (installed[item.key]) return;
await item.onInstall();
// Only refresh in select mode
@ -256,7 +266,7 @@ export default function IntegrationList({
"Slack",
"X(Twitter)",
"WhatsApp",
"LinkedIn",
// "LinkedIn", // LinkedIn OAuth is now supported
"Reddit",
"Github",
];

View file

@ -19,6 +19,7 @@ import {
proxyFetchPut,
proxyFetchDelete,
fetchDelete,
fetchPost,
} from "@/api/http";
import { capitalizeFirstLetter } from "@/lib";
import { useAuthStore } from "@/store/authStore";
@ -75,7 +76,7 @@ export function useIntegrationManagement(items: IntegrationItem[]) {
// Recalculate installed status when items or configs change
useEffect(() => {
const map: { [key: string]: boolean } = {};
items.forEach((item) => {
if (item.key === "Google Calendar") {
// Only mark installed when refresh token is present (auth completed)
@ -86,6 +87,15 @@ export function useIntegrationManagement(items: IntegrationItem[]) {
c.config_value && String(c.config_value).length > 0
);
map[item.key] = hasRefreshToken;
} else if (item.key === "LinkedIn") {
// LinkedIn: check if access token is present
const hasAccessToken = configs.some(
(c: any) =>
c.config_group?.toLowerCase() === "linkedin" &&
c.config_name === "LINKEDIN_ACCESS_TOKEN" &&
c.config_value && String(c.config_value).length > 0
);
map[item.key] = hasAccessToken;
} else {
// For other integrations, use config_group presence
const hasConfig = configs.some(
@ -94,7 +104,7 @@ export function useIntegrationManagement(items: IntegrationItem[]) {
map[item.key] = hasConfig;
}
});
setInstalled(map);
}, [items, configs]);
@ -187,6 +197,43 @@ export function useIntegrationManagement(items: IntegrationItem[]) {
"Slack authorization successful, but access_token not found or env configuration not found"
);
}
} else if (provider === "linkedin") {
// LinkedIn OAuth: save token via local backend endpoint and config
if (tokenResult.access_token) {
try {
// Save token to local backend toolkit (token file is stored locally)
await fetchPost("/linkedin/save-token", {
access_token: tokenResult.access_token,
refresh_token: tokenResult.refresh_token,
expires_in: tokenResult.expires_in,
});
// Also save to config for UI status tracking
await saveEnvAndConfig(
"LinkedIn",
"LINKEDIN_ACCESS_TOKEN",
tokenResult.access_token
);
if (tokenResult.refresh_token) {
await saveEnvAndConfig(
"LinkedIn",
"LINKEDIN_REFRESH_TOKEN",
tokenResult.refresh_token
);
}
await fetchInstalled();
console.log(
"LinkedIn authorization successful and configuration saved!"
);
} catch (e) {
console.error("Failed to save LinkedIn token:", e);
}
} else {
console.log(
"LinkedIn authorization successful, but access_token not found"
);
}
}
} catch (e: any) {
console.log(`${data.provider} authorization failed: ${e.message || e}`);
@ -254,7 +301,7 @@ export function useIntegrationManagement(items: IntegrationItem[]) {
}
}
// Clean up authentication tokens for Google Calendar and Notion
// Clean up authentication tokens for Google Calendar, Notion, and LinkedIn
if (item.key === "Google Calendar") {
try {
await fetchDelete("/uninstall/tool/google_calendar");
@ -269,6 +316,13 @@ export function useIntegrationManagement(items: IntegrationItem[]) {
} catch (e) {
console.log("Failed to clean up Notion tokens:", e);
}
} else if (item.key === "LinkedIn") {
try {
await fetchDelete("/uninstall/tool/linkedin");
console.log("Cleaned up LinkedIn authentication tokens");
} catch (e) {
console.log("Failed to clean up LinkedIn tokens:", e);
}
}
// Update configs after deletion