diff --git a/surfsense_backend/app/routes/gateway_webhook_routes.py b/surfsense_backend/app/routes/gateway_webhook_routes.py index adfcd56c6..d4b574c26 100644 --- a/surfsense_backend/app/routes/gateway_webhook_routes.py +++ b/surfsense_backend/app/routes/gateway_webhook_routes.py @@ -53,6 +53,7 @@ from app.observability.metrics import ( ) from app.users import current_active_user from app.utils.oauth_security import OAuthStateManager, TokenEncryption +from app.utils.rbac import check_search_space_access router = APIRouter(prefix="/gateway", tags=["gateway"]) logger = logging.getLogger(__name__) @@ -164,6 +165,10 @@ class StartBindingResponse(BaseModel): expires_at: datetime +class UpdateBindingSearchSpaceRequest(BaseModel): + search_space_id: int + + def _classify_telegram_event(payload: dict[str, Any]) -> str: if "message" in payload: return "message" @@ -182,9 +187,11 @@ def _telegram_message(payload: dict[str, Any]) -> dict[str, Any] | None: async def install_slack_gateway( search_space_id: int, user: User = Depends(current_active_user), + session: AsyncSession = Depends(get_async_session), ) -> dict[str, str]: if not config.GATEWAY_SLACK_CLIENT_ID: raise HTTPException(status_code=500, detail="Slack gateway OAuth is not configured") + await check_search_space_access(session, user, search_space_id) state = _get_state_manager().generate_secure_state(search_space_id, user.id) auth_params = { "client_id": config.GATEWAY_SLACK_CLIENT_ID, @@ -329,9 +336,11 @@ async def slack_gateway_callback( async def install_discord_gateway( search_space_id: int, user: User = Depends(current_active_user), + session: AsyncSession = Depends(get_async_session), ) -> dict[str, str]: if not config.DISCORD_CLIENT_ID: raise HTTPException(status_code=500, detail="Discord gateway OAuth is not configured") + await check_search_space_access(session, user, search_space_id) state = _get_state_manager().generate_secure_state(search_space_id, user.id) auth_params = { "client_id": config.DISCORD_CLIENT_ID, @@ -613,6 +622,7 @@ async def start_binding( user: User = Depends(current_active_user), session: AsyncSession = Depends(get_async_session), ) -> StartBindingResponse: + await check_search_space_access(session, user, body.search_space_id) code = generate_pairing_code() if body.platform == ExternalChatPlatform.TELEGRAM: account = await get_or_create_system_telegram_account(session) @@ -692,6 +702,62 @@ async def list_bindings( ] +@router.get("/connections") +async def list_connections( + user: User = Depends(current_active_user), + session: AsyncSession = Depends(get_async_session), +) -> list[dict[str, Any]]: + result = await session.execute( + select(ExternalChatBinding, ExternalChatAccount) + .join(ExternalChatAccount, ExternalChatBinding.account_id == ExternalChatAccount.id) + .where( + ExternalChatBinding.user_id == user.id, + ExternalChatBinding.state.in_( + [ExternalChatBindingState.BOUND, ExternalChatBindingState.SUSPENDED] + ), + ) + ) + + connections: list[dict[str, Any]] = [] + for binding, account in result.all(): + binding_metadata = binding.external_metadata or {} + kind = str(binding_metadata.get("kind") or "") + if kind in {"slack_thread", "discord_thread"}: + continue + + account_state = account.cursor_state or {} + workspace_name = None + workspace_id = None + if account.platform == ExternalChatPlatform.SLACK: + workspace_name = account_state.get("team_name") + workspace_id = account_state.get("team_id") + elif account.platform == ExternalChatPlatform.DISCORD: + workspace_name = account_state.get("guild_name") + workspace_id = account_state.get("guild_id") + elif account.platform == ExternalChatPlatform.WHATSAPP: + workspace_name = account_state.get("display_phone_number") + workspace_id = account_state.get("phone_number_id") + + connections.append( + { + "id": binding.id, + "platform": account.platform.value, + "state": binding.state.value, + "search_space_id": binding.search_space_id, + "display_name": binding.external_display_name + or binding.external_username + or workspace_name, + "external_username": binding.external_username, + "workspace_name": workspace_name, + "workspace_id": workspace_id, + "health_status": account.health_status.value, + "suspended_reason": binding.suspended_reason, + } + ) + + return connections + + @router.get("/platforms") async def list_platforms( user: User = Depends(current_active_user), @@ -716,6 +782,31 @@ async def list_platforms( ] +@router.patch("/bindings/{binding_id}/search-space") +async def update_binding_search_space( + binding_id: int, + body: UpdateBindingSearchSpaceRequest, + user: User = Depends(current_active_user), + session: AsyncSession = Depends(get_async_session), +) -> dict[str, bool]: + binding = await session.get(ExternalChatBinding, binding_id) + if binding is None or binding.user_id != user.id: + raise HTTPException(status_code=404, detail="Binding not found") + if binding.state not in { + ExternalChatBindingState.BOUND, + ExternalChatBindingState.SUSPENDED, + }: + raise HTTPException(status_code=400, detail="Only active bindings can be routed") + + await check_search_space_access(session, user, body.search_space_id) + if binding.search_space_id != body.search_space_id: + binding.search_space_id = body.search_space_id + binding.new_chat_thread_id = None + binding.updated_at = datetime.now(UTC) + await session.commit() + return {"ok": True} + + @router.delete("/bindings/{binding_id}") async def delete_binding( binding_id: int, diff --git a/surfsense_backend/app/routes/gateway_whatsapp_baileys_routes.py b/surfsense_backend/app/routes/gateway_whatsapp_baileys_routes.py index 24209f86f..fa49d0558 100644 --- a/surfsense_backend/app/routes/gateway_whatsapp_baileys_routes.py +++ b/surfsense_backend/app/routes/gateway_whatsapp_baileys_routes.py @@ -21,6 +21,7 @@ from app.db import ( ) from app.gateway.whatsapp.adapter_baileys import WhatsAppBaileysAdapter from app.users import current_active_user +from app.utils.rbac import check_search_space_access router = APIRouter(prefix="/gateway/whatsapp/baileys", tags=["gateway"]) @@ -61,6 +62,7 @@ async def request_pairing_code( session: AsyncSession = Depends(get_async_session), ) -> dict[str, Any]: _ensure_baileys_enabled() + await check_search_space_access(session, user, body.search_space_id) adapter = WhatsAppBaileysAdapter() try: pairing = await adapter.request_pairing_code(phone_number=body.phone_number) diff --git a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MessagingChannelsContent.tsx b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MessagingChannelsContent.tsx index 7d6f7cba6..ff6365499 100644 --- a/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MessagingChannelsContent.tsx +++ b/surfsense_web/app/dashboard/[search_space_id]/user-settings/components/MessagingChannelsContent.tsx @@ -4,21 +4,33 @@ import { MessageCircle, RefreshCw, ShieldAlert } from "lucide-react"; import { useParams } from "next/navigation"; import { QRCodeSVG } from "qrcode.react"; import { useCallback, useEffect, useState, useTransition } from "react"; +import { toast } from "sonner"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import type { SearchSpace } from "@/contracts/types/search-space.types"; +import { searchSpacesApiService } from "@/lib/apis/search-spaces-api.service"; import { authenticatedFetch } from "@/lib/auth-utils"; import { BACKEND_URL } from "@/lib/env-config"; -type Binding = { +type GatewayConnection = { id: number; - platform?: string; + platform: string; state: string; search_space_id: number; - external_display_name?: string | null; + display_name?: string | null; external_username?: string | null; + workspace_name?: string | null; + workspace_id?: string | null; + health_status: string; suspended_reason?: string | null; - external_metadata?: Record | null; }; type Platform = { @@ -53,8 +65,9 @@ export function MessagingChannelsContent() { const whatsappMode = process.env.NEXT_PUBLIC_GATEWAY_WHATSAPP_INTAKE_MODE ?? "disabled"; const slackGatewayEnabled = process.env.NEXT_PUBLIC_GATEWAY_SLACK_ENABLED === "true"; const discordGatewayEnabled = process.env.NEXT_PUBLIC_GATEWAY_DISCORD_ENABLED === "true"; - const [bindings, setBindings] = useState([]); + const [connections, setConnections] = useState([]); const [platforms, setPlatforms] = useState([]); + const [searchSpaces, setSearchSpaces] = useState([]); const [pairing, setPairing] = useState(null); const [pairingPlatform, setPairingPlatform] = useState(null); const [baileysHealth, setBaileysHealth] = useState(null); @@ -63,12 +76,14 @@ export function MessagingChannelsContent() { const refresh = useCallback(async () => { setLoading(true); - const [bindingsRes, platformsRes] = await Promise.all([ - authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/bindings`), + const [connectionsRes, platformsRes, spaces] = await Promise.all([ + authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/connections`), authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/platforms`), + searchSpacesApiService.getSearchSpaces(), ]); - setBindings(await bindingsRes.json()); + setConnections(await connectionsRes.json()); setPlatforms(await platformsRes.json()); + setSearchSpaces(spaces); setLoading(false); }, []); @@ -135,6 +150,31 @@ export function MessagingChannelsContent() { await refresh(); } + async function updateConnectionSearchSpace(id: number, nextSearchSpaceId: string) { + const previousConnections = connections; + const parsedSearchSpaceId = Number(nextSearchSpaceId); + setConnections((current) => + current.map((connection) => + connection.id === id ? { ...connection, search_space_id: parsedSearchSpaceId } : connection + ) + ); + const res = await authenticatedFetch( + `${BACKEND_URL}/api/v1/gateway/bindings/${id}/search-space`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ search_space_id: parsedSearchSpaceId }), + } + ); + if (!res.ok) { + setConnections(previousConnections); + toast.error("Failed to update messaging route"); + return; + } + toast.success("Messaging route updated"); + await refresh(); + } + async function resume(id: number) { await authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/bindings/${id}/resume`, { method: "POST", @@ -147,12 +187,27 @@ export function MessagingChannelsContent() { const slack = platforms.find((p) => p.platform === "slack"); const discord = platforms.find((p) => p.platform === "discord"); const baileysQr = baileysHealth?.qr || null; - const activeBindings = bindings.filter( - (binding) => - binding.search_space_id === searchSpaceId && - binding.external_metadata?.kind !== "slack_thread" && - binding.external_metadata?.kind !== "discord_thread" - ); + const currentSearchSpaceName = + searchSpaces.find((space) => space.id === searchSpaceId)?.name || "this search space"; + const platformLabel = (platform: string) => { + switch (platform) { + case "discord": + return "Discord"; + case "slack": + return "Slack"; + case "telegram": + return "Telegram"; + case "whatsapp": + return "WhatsApp"; + default: + return platform; + } + }; + const connectionTitle = (connection: GatewayConnection) => + connection.workspace_name || + connection.display_name || + connection.external_username || + `${platformLabel(connection.platform)} connection`; const renderPairingPanel = (platform: PairingPlatform) => { if (!pairing || pairingPlatform !== platform) return null; @@ -221,16 +276,15 @@ export function MessagingChannelsContent() {
- +

- Slack search remains controlled by the Slack connector in the connector popup. + New Slack workspace connections will route to {currentSearchSpaceName} first. You can + change each connection's search space below.

@@ -255,16 +309,15 @@ export function MessagingChannelsContent() {
- +

- Discord search remains controlled by the Discord connector in the connector popup. + New Discord server connections will route to {currentSearchSpaceName} first. You can + change each connection's search space below.

@@ -329,39 +382,58 @@ export function MessagingChannelsContent() { - Active Chats + Connected Messaging Channels +

+ Choose which search space each external channel should use when messages arrive. +

- {activeBindings.length === 0 ? ( -

No external chats connected yet.

+ {connections.length === 0 ? ( +

No messaging channels connected yet.

) : ( - activeBindings.map((binding) => ( + connections.map((connection) => (
-
-

- {binding.external_display_name || - binding.external_username || - `Binding ${binding.id}`} +

+

{connectionTitle(connection)}

+

+ {platformLabel(connection.platform)} + {connection.external_username ? ` · ${connection.external_username}` : ""} + {connection.state ? ` · ${connection.state}` : ""}

-

{binding.state}

- {binding.suspended_reason ? ( + {connection.suspended_reason ? (

- {binding.suspended_reason} + {connection.suspended_reason}

) : null}
-
- {binding.state === "suspended" ? ( - ) : null} -