diff --git a/backend/app/controller/tool_controller.py b/backend/app/controller/tool_controller.py index da5234213..c34cc22aa 100644 --- a/backend/app/controller/tool_controller.py +++ b/backend/app/controller/tool_controller.py @@ -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)}" ) diff --git a/backend/app/utils/toolkit/linkedin_toolkit.py b/backend/app/utils/toolkit/linkedin_toolkit.py index a445d4e2f..105fbfcee 100644 --- a/backend/app/utils/toolkit/linkedin_toolkit.py +++ b/backend/app/utils/toolkit/linkedin_toolkit.py @@ -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)} diff --git a/server/app/component/oauth_adapter.py b/server/app/component/oauth_adapter.py index 75788caa9..4e729ffa5 100644 --- a/server/app/component/oauth_adapter.py +++ b/server/app/component/oauth_adapter.py @@ -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): diff --git a/server/app/model/config/config.py b/server/app/model/config/config.py index 8b681b74f..aea087a4f 100644 --- a/server/app/model/config/config.py +++ b/server/app/model/config/config.py @@ -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: { diff --git a/src/components/IntegrationList/index.tsx b/src/components/IntegrationList/index.tsx index 626f08479..3947222a6 100644 --- a/src/components/IntegrationList/index.tsx +++ b/src/components/IntegrationList/index.tsx @@ -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", ]; diff --git a/src/hooks/useIntegrationManagement.ts b/src/hooks/useIntegrationManagement.ts index 74239301f..5be16a04d 100644 --- a/src/hooks/useIntegrationManagement.ts +++ b/src/hooks/useIntegrationManagement.ts @@ -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