mirror of
https://github.com/MODSetter/SurfSense.git
synced 2026-06-04 20:05:16 +02:00
feat(gateway): implement search space management for messaging channels
This commit is contained in:
parent
455a3ee021
commit
2d1a6be776
3 changed files with 205 additions and 40 deletions
|
|
@ -53,6 +53,7 @@ from app.observability.metrics import (
|
||||||
)
|
)
|
||||||
from app.users import current_active_user
|
from app.users import current_active_user
|
||||||
from app.utils.oauth_security import OAuthStateManager, TokenEncryption
|
from app.utils.oauth_security import OAuthStateManager, TokenEncryption
|
||||||
|
from app.utils.rbac import check_search_space_access
|
||||||
|
|
||||||
router = APIRouter(prefix="/gateway", tags=["gateway"])
|
router = APIRouter(prefix="/gateway", tags=["gateway"])
|
||||||
logger = logging.getLogger(__name__)
|
logger = logging.getLogger(__name__)
|
||||||
|
|
@ -164,6 +165,10 @@ class StartBindingResponse(BaseModel):
|
||||||
expires_at: datetime
|
expires_at: datetime
|
||||||
|
|
||||||
|
|
||||||
|
class UpdateBindingSearchSpaceRequest(BaseModel):
|
||||||
|
search_space_id: int
|
||||||
|
|
||||||
|
|
||||||
def _classify_telegram_event(payload: dict[str, Any]) -> str:
|
def _classify_telegram_event(payload: dict[str, Any]) -> str:
|
||||||
if "message" in payload:
|
if "message" in payload:
|
||||||
return "message"
|
return "message"
|
||||||
|
|
@ -182,9 +187,11 @@ def _telegram_message(payload: dict[str, Any]) -> dict[str, Any] | None:
|
||||||
async def install_slack_gateway(
|
async def install_slack_gateway(
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
if not config.GATEWAY_SLACK_CLIENT_ID:
|
if not config.GATEWAY_SLACK_CLIENT_ID:
|
||||||
raise HTTPException(status_code=500, detail="Slack gateway OAuth is not configured")
|
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)
|
state = _get_state_manager().generate_secure_state(search_space_id, user.id)
|
||||||
auth_params = {
|
auth_params = {
|
||||||
"client_id": config.GATEWAY_SLACK_CLIENT_ID,
|
"client_id": config.GATEWAY_SLACK_CLIENT_ID,
|
||||||
|
|
@ -329,9 +336,11 @@ async def slack_gateway_callback(
|
||||||
async def install_discord_gateway(
|
async def install_discord_gateway(
|
||||||
search_space_id: int,
|
search_space_id: int,
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
|
session: AsyncSession = Depends(get_async_session),
|
||||||
) -> dict[str, str]:
|
) -> dict[str, str]:
|
||||||
if not config.DISCORD_CLIENT_ID:
|
if not config.DISCORD_CLIENT_ID:
|
||||||
raise HTTPException(status_code=500, detail="Discord gateway OAuth is not configured")
|
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)
|
state = _get_state_manager().generate_secure_state(search_space_id, user.id)
|
||||||
auth_params = {
|
auth_params = {
|
||||||
"client_id": config.DISCORD_CLIENT_ID,
|
"client_id": config.DISCORD_CLIENT_ID,
|
||||||
|
|
@ -613,6 +622,7 @@ async def start_binding(
|
||||||
user: User = Depends(current_active_user),
|
user: User = Depends(current_active_user),
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
) -> StartBindingResponse:
|
) -> StartBindingResponse:
|
||||||
|
await check_search_space_access(session, user, body.search_space_id)
|
||||||
code = generate_pairing_code()
|
code = generate_pairing_code()
|
||||||
if body.platform == ExternalChatPlatform.TELEGRAM:
|
if body.platform == ExternalChatPlatform.TELEGRAM:
|
||||||
account = await get_or_create_system_telegram_account(session)
|
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")
|
@router.get("/platforms")
|
||||||
async def list_platforms(
|
async def list_platforms(
|
||||||
user: User = Depends(current_active_user),
|
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}")
|
@router.delete("/bindings/{binding_id}")
|
||||||
async def delete_binding(
|
async def delete_binding(
|
||||||
binding_id: int,
|
binding_id: int,
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ from app.db import (
|
||||||
)
|
)
|
||||||
from app.gateway.whatsapp.adapter_baileys import WhatsAppBaileysAdapter
|
from app.gateway.whatsapp.adapter_baileys import WhatsAppBaileysAdapter
|
||||||
from app.users import current_active_user
|
from app.users import current_active_user
|
||||||
|
from app.utils.rbac import check_search_space_access
|
||||||
|
|
||||||
router = APIRouter(prefix="/gateway/whatsapp/baileys", tags=["gateway"])
|
router = APIRouter(prefix="/gateway/whatsapp/baileys", tags=["gateway"])
|
||||||
|
|
||||||
|
|
@ -61,6 +62,7 @@ async def request_pairing_code(
|
||||||
session: AsyncSession = Depends(get_async_session),
|
session: AsyncSession = Depends(get_async_session),
|
||||||
) -> dict[str, Any]:
|
) -> dict[str, Any]:
|
||||||
_ensure_baileys_enabled()
|
_ensure_baileys_enabled()
|
||||||
|
await check_search_space_access(session, user, body.search_space_id)
|
||||||
adapter = WhatsAppBaileysAdapter()
|
adapter = WhatsAppBaileysAdapter()
|
||||||
try:
|
try:
|
||||||
pairing = await adapter.request_pairing_code(phone_number=body.phone_number)
|
pairing = await adapter.request_pairing_code(phone_number=body.phone_number)
|
||||||
|
|
|
||||||
|
|
@ -4,21 +4,33 @@ import { MessageCircle, RefreshCw, ShieldAlert } from "lucide-react";
|
||||||
import { useParams } from "next/navigation";
|
import { useParams } from "next/navigation";
|
||||||
import { QRCodeSVG } from "qrcode.react";
|
import { QRCodeSVG } from "qrcode.react";
|
||||||
import { useCallback, useEffect, useState, useTransition } from "react";
|
import { useCallback, useEffect, useState, useTransition } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
import { Button } from "@/components/ui/button";
|
import { Button } from "@/components/ui/button";
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
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 { authenticatedFetch } from "@/lib/auth-utils";
|
||||||
import { BACKEND_URL } from "@/lib/env-config";
|
import { BACKEND_URL } from "@/lib/env-config";
|
||||||
|
|
||||||
type Binding = {
|
type GatewayConnection = {
|
||||||
id: number;
|
id: number;
|
||||||
platform?: string;
|
platform: string;
|
||||||
state: string;
|
state: string;
|
||||||
search_space_id: number;
|
search_space_id: number;
|
||||||
external_display_name?: string | null;
|
display_name?: string | null;
|
||||||
external_username?: string | null;
|
external_username?: string | null;
|
||||||
|
workspace_name?: string | null;
|
||||||
|
workspace_id?: string | null;
|
||||||
|
health_status: string;
|
||||||
suspended_reason?: string | null;
|
suspended_reason?: string | null;
|
||||||
external_metadata?: Record<string, unknown> | null;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
type Platform = {
|
type Platform = {
|
||||||
|
|
@ -53,8 +65,9 @@ export function MessagingChannelsContent() {
|
||||||
const whatsappMode = process.env.NEXT_PUBLIC_GATEWAY_WHATSAPP_INTAKE_MODE ?? "disabled";
|
const whatsappMode = process.env.NEXT_PUBLIC_GATEWAY_WHATSAPP_INTAKE_MODE ?? "disabled";
|
||||||
const slackGatewayEnabled = process.env.NEXT_PUBLIC_GATEWAY_SLACK_ENABLED === "true";
|
const slackGatewayEnabled = process.env.NEXT_PUBLIC_GATEWAY_SLACK_ENABLED === "true";
|
||||||
const discordGatewayEnabled = process.env.NEXT_PUBLIC_GATEWAY_DISCORD_ENABLED === "true";
|
const discordGatewayEnabled = process.env.NEXT_PUBLIC_GATEWAY_DISCORD_ENABLED === "true";
|
||||||
const [bindings, setBindings] = useState<Binding[]>([]);
|
const [connections, setConnections] = useState<GatewayConnection[]>([]);
|
||||||
const [platforms, setPlatforms] = useState<Platform[]>([]);
|
const [platforms, setPlatforms] = useState<Platform[]>([]);
|
||||||
|
const [searchSpaces, setSearchSpaces] = useState<SearchSpace[]>([]);
|
||||||
const [pairing, setPairing] = useState<Pairing | null>(null);
|
const [pairing, setPairing] = useState<Pairing | null>(null);
|
||||||
const [pairingPlatform, setPairingPlatform] = useState<PairingPlatform | null>(null);
|
const [pairingPlatform, setPairingPlatform] = useState<PairingPlatform | null>(null);
|
||||||
const [baileysHealth, setBaileysHealth] = useState<BaileysHealth | null>(null);
|
const [baileysHealth, setBaileysHealth] = useState<BaileysHealth | null>(null);
|
||||||
|
|
@ -63,12 +76,14 @@ export function MessagingChannelsContent() {
|
||||||
|
|
||||||
const refresh = useCallback(async () => {
|
const refresh = useCallback(async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const [bindingsRes, platformsRes] = await Promise.all([
|
const [connectionsRes, platformsRes, spaces] = await Promise.all([
|
||||||
authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/bindings`),
|
authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/connections`),
|
||||||
authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/platforms`),
|
authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/platforms`),
|
||||||
|
searchSpacesApiService.getSearchSpaces(),
|
||||||
]);
|
]);
|
||||||
setBindings(await bindingsRes.json());
|
setConnections(await connectionsRes.json());
|
||||||
setPlatforms(await platformsRes.json());
|
setPlatforms(await platformsRes.json());
|
||||||
|
setSearchSpaces(spaces);
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
|
@ -135,6 +150,31 @@ export function MessagingChannelsContent() {
|
||||||
await refresh();
|
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) {
|
async function resume(id: number) {
|
||||||
await authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/bindings/${id}/resume`, {
|
await authenticatedFetch(`${BACKEND_URL}/api/v1/gateway/bindings/${id}/resume`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|
@ -147,12 +187,27 @@ export function MessagingChannelsContent() {
|
||||||
const slack = platforms.find((p) => p.platform === "slack");
|
const slack = platforms.find((p) => p.platform === "slack");
|
||||||
const discord = platforms.find((p) => p.platform === "discord");
|
const discord = platforms.find((p) => p.platform === "discord");
|
||||||
const baileysQr = baileysHealth?.qr || null;
|
const baileysQr = baileysHealth?.qr || null;
|
||||||
const activeBindings = bindings.filter(
|
const currentSearchSpaceName =
|
||||||
(binding) =>
|
searchSpaces.find((space) => space.id === searchSpaceId)?.name || "this search space";
|
||||||
binding.search_space_id === searchSpaceId &&
|
const platformLabel = (platform: string) => {
|
||||||
binding.external_metadata?.kind !== "slack_thread" &&
|
switch (platform) {
|
||||||
binding.external_metadata?.kind !== "discord_thread"
|
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) => {
|
const renderPairingPanel = (platform: PairingPlatform) => {
|
||||||
if (!pairing || pairingPlatform !== platform) return null;
|
if (!pairing || pairingPlatform !== platform) return null;
|
||||||
|
|
||||||
|
|
@ -221,16 +276,15 @@ export function MessagingChannelsContent() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button onClick={installSlackGateway}>
|
<Button onClick={installSlackGateway}>Add Slack Workspace</Button>
|
||||||
{slack ? "Reconnect Slack Bot" : "Enable Slack Bot"}
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" onClick={refresh} disabled={loading}>
|
<Button variant="outline" onClick={refresh} disabled={loading}>
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -255,16 +309,15 @@ export function MessagingChannelsContent() {
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<CardContent className="space-y-4">
|
||||||
<div className="flex flex-wrap gap-2">
|
<div className="flex flex-wrap gap-2">
|
||||||
<Button onClick={installDiscordGateway}>
|
<Button onClick={installDiscordGateway}>Add Discord Server</Button>
|
||||||
{discord ? "Reconnect Discord Bot" : "Enable Discord Bot"}
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" onClick={refresh} disabled={loading}>
|
<Button variant="outline" onClick={refresh} disabled={loading}>
|
||||||
<RefreshCw className="mr-2 h-4 w-4" />
|
<RefreshCw className="mr-2 h-4 w-4" />
|
||||||
Refresh
|
Refresh
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground">
|
<p className="text-xs text-muted-foreground">
|
||||||
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.
|
||||||
</p>
|
</p>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -329,39 +382,58 @@ export function MessagingChannelsContent() {
|
||||||
|
|
||||||
<Card>
|
<Card>
|
||||||
<CardHeader>
|
<CardHeader>
|
||||||
<CardTitle className="text-base">Active Chats</CardTitle>
|
<CardTitle className="text-base">Connected Messaging Channels</CardTitle>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
Choose which search space each external channel should use when messages arrive.
|
||||||
|
</p>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{activeBindings.length === 0 ? (
|
{connections.length === 0 ? (
|
||||||
<p className="text-sm text-muted-foreground">No external chats connected yet.</p>
|
<p className="text-sm text-muted-foreground">No messaging channels connected yet.</p>
|
||||||
) : (
|
) : (
|
||||||
activeBindings.map((binding) => (
|
connections.map((connection) => (
|
||||||
<div
|
<div
|
||||||
key={binding.id}
|
key={connection.id}
|
||||||
className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-border p-3"
|
className="flex flex-wrap items-center justify-between gap-3 rounded-md border border-border p-3"
|
||||||
>
|
>
|
||||||
<div>
|
<div className="min-w-0">
|
||||||
<p className="text-sm font-medium">
|
<p className="text-sm font-medium">{connectionTitle(connection)}</p>
|
||||||
{binding.external_display_name ||
|
<p className="text-xs text-muted-foreground">
|
||||||
binding.external_username ||
|
{platformLabel(connection.platform)}
|
||||||
`Binding ${binding.id}`}
|
{connection.external_username ? ` · ${connection.external_username}` : ""}
|
||||||
|
{connection.state ? ` · ${connection.state}` : ""}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-xs text-muted-foreground">{binding.state}</p>
|
{connection.suspended_reason ? (
|
||||||
{binding.suspended_reason ? (
|
|
||||||
<p className="mt-1 flex items-center gap-1 text-xs text-destructive">
|
<p className="mt-1 flex items-center gap-1 text-xs text-destructive">
|
||||||
<ShieldAlert className="h-3 w-3" />
|
<ShieldAlert className="h-3 w-3" />
|
||||||
{binding.suspended_reason}
|
{connection.suspended_reason}
|
||||||
</p>
|
</p>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex gap-2">
|
<div className="flex flex-wrap items-center gap-2">
|
||||||
{binding.state === "suspended" ? (
|
<Select
|
||||||
<Button size="sm" variant="outline" onClick={() => resume(binding.id)}>
|
value={String(connection.search_space_id)}
|
||||||
|
onValueChange={(value) => updateConnectionSearchSpace(connection.id, value)}
|
||||||
|
disabled={searchSpaces.length === 0 || isPending}
|
||||||
|
>
|
||||||
|
<SelectTrigger className="w-[220px]">
|
||||||
|
<SelectValue placeholder="Select search space" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{searchSpaces.map((space) => (
|
||||||
|
<SelectItem key={space.id} value={String(space.id)}>
|
||||||
|
{space.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
{connection.state === "suspended" ? (
|
||||||
|
<Button size="sm" variant="outline" onClick={() => resume(connection.id)}>
|
||||||
Resume
|
Resume
|
||||||
</Button>
|
</Button>
|
||||||
) : null}
|
) : null}
|
||||||
<Button size="sm" variant="destructive" onClick={() => revoke(binding.id)}>
|
<Button size="sm" variant="destructive" onClick={() => revoke(connection.id)}>
|
||||||
Revoke
|
Disconnect
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue