|
| 1 | +/** |
| 2 | + * Copyright 2026 Redpanda Data, Inc. |
| 3 | + * |
| 4 | + * Use of this software is governed by the Business Source License |
| 5 | + * included in the file https://github.com/redpanda-data/redpanda/blob/dev/licenses/bsl.md |
| 6 | + * |
| 7 | + * As of the Change Date specified in that file, in accordance with |
| 8 | + * the Business Source License, use of this software will be governed |
| 9 | + * by the Apache License, Version 2.0 |
| 10 | + */ |
| 11 | + |
| 12 | +import { create } from '@bufbuild/protobuf'; |
| 13 | +import { FieldMaskSchema } from '@bufbuild/protobuf/wkt'; |
| 14 | +import { getRouteApi } from '@tanstack/react-router'; |
| 15 | +import { Button } from 'components/redpanda-ui/components/button'; |
| 16 | +import { Card, CardContent, CardHeader, CardTitle } from 'components/redpanda-ui/components/card'; |
| 17 | +import { DynamicCodeBlock } from 'components/redpanda-ui/components/code-block-dynamic'; |
| 18 | +import { Input } from 'components/redpanda-ui/components/input'; |
| 19 | +import { Label } from 'components/redpanda-ui/components/label'; |
| 20 | +import { Switch } from 'components/redpanda-ui/components/switch'; |
| 21 | +import { Text } from 'components/redpanda-ui/components/typography'; |
| 22 | +import { SecretSelector } from 'components/ui/secret/secret-selector'; |
| 23 | +import { Edit, Save } from 'lucide-react'; |
| 24 | +import { Scope } from 'protogen/redpanda/api/dataplane/v1/secret_pb'; |
| 25 | +import { |
| 26 | + AIAgentTeamsBridgeSchema, |
| 27 | + AIAgentUpdateSchema, |
| 28 | + UpdateAIAgentRequestSchema, |
| 29 | +} from 'protogen/redpanda/api/dataplane/v1alpha3/ai_agent_pb'; |
| 30 | +import { useMemo, useState } from 'react'; |
| 31 | +import { useGetAIAgentQuery, useUpdateAIAgentMutation } from 'react-query/api/ai-agent'; |
| 32 | +import { useListSecretsQuery } from 'react-query/api/secret'; |
| 33 | +import { toast } from 'sonner'; |
| 34 | +import { formatToastErrorMessageGRPC } from 'utils/toast.utils'; |
| 35 | + |
| 36 | +const routeApi = getRouteApi('/agents/$id/'); |
| 37 | + |
| 38 | +const SECRET_TEMPLATE_REGEX = /^\$\{secrets\.([^}]+)\}$/; |
| 39 | + |
| 40 | +const TEAMS_SECRET_TEXT = { |
| 41 | + dialogDescription: 'Create a new secret for your Microsoft Teams bot client secret.', |
| 42 | + secretNamePlaceholder: 'e.g., TEAMS_CLIENT_SECRET', |
| 43 | + secretValuePlaceholder: 'Enter your client secret...', |
| 44 | + secretValueDescription: 'Your Microsoft Teams bot application client secret', |
| 45 | + emptyStateDescription: 'Create a secret to securely store your Teams bot client secret', |
| 46 | +}; |
| 47 | + |
| 48 | +type TeamsBridgeState = { |
| 49 | + enabled: boolean; |
| 50 | + botAppId: string; |
| 51 | + botTenantId: string; |
| 52 | + botAppSecretName: string; |
| 53 | +}; |
| 54 | + |
| 55 | +const extractSecretName = (ref: string): string => { |
| 56 | + const match = ref.match(SECRET_TEMPLATE_REGEX); |
| 57 | + return match ? match[1] : ''; |
| 58 | +}; |
| 59 | + |
| 60 | +const getMessagingEndpointUrl = (agentUrl: string, agentId: string): string => { |
| 61 | + try { |
| 62 | + const url = new URL(agentUrl); |
| 63 | + const hostParts = url.hostname.split('.'); |
| 64 | + if (hostParts.length < 2) { |
| 65 | + return ''; |
| 66 | + } |
| 67 | + const clusterDomain = hostParts.slice(1).join('.'); |
| 68 | + return `https://msteams-bridge.${clusterDomain}/msteams/v1/${agentId}`; |
| 69 | + } catch { |
| 70 | + return ''; |
| 71 | + } |
| 72 | +}; |
| 73 | + |
| 74 | +// biome-ignore lint/complexity/noExcessiveCognitiveComplexity: Integrations tab with edit/view mode conditionals |
| 75 | +export const AIAgentIntegrationsTab = () => { |
| 76 | + const { id } = routeApi.useParams(); |
| 77 | + const { data: aiAgentData } = useGetAIAgentQuery({ id: id || '' }, { enabled: !!id }); |
| 78 | + const { mutateAsync: updateAIAgent, isPending: isUpdatePending } = useUpdateAIAgentMutation(); |
| 79 | + const { data: secretsData } = useListSecretsQuery(); |
| 80 | + |
| 81 | + const [isEditing, setIsEditing] = useState(false); |
| 82 | + const [editedState, setEditedState] = useState<TeamsBridgeState | null>(null); |
| 83 | + |
| 84 | + const agent = aiAgentData?.aiAgent; |
| 85 | + |
| 86 | + const availableSecrets = useMemo(() => { |
| 87 | + if (!secretsData?.secrets) { |
| 88 | + return []; |
| 89 | + } |
| 90 | + return secretsData.secrets |
| 91 | + .filter((secret): secret is NonNullable<typeof secret> & { id: string } => !!secret?.id) |
| 92 | + .map((secret) => ({ id: secret.id, name: secret.id })); |
| 93 | + }, [secretsData]); |
| 94 | + |
| 95 | + if (!agent) { |
| 96 | + return null; |
| 97 | + } |
| 98 | + |
| 99 | + const displayState: TeamsBridgeState = editedState || { |
| 100 | + enabled: agent.teamsBridge?.enabled ?? false, |
| 101 | + botAppId: agent.teamsBridge?.botAppId ?? '', |
| 102 | + botTenantId: agent.teamsBridge?.botTenantId ?? '', |
| 103 | + botAppSecretName: extractSecretName(agent.teamsBridge?.botAppSecretRef ?? ''), |
| 104 | + }; |
| 105 | + |
| 106 | + const updateField = (updates: Partial<TeamsBridgeState>) => { |
| 107 | + setEditedState({ ...displayState, ...updates }); |
| 108 | + }; |
| 109 | + |
| 110 | + const handleSave = async () => { |
| 111 | + if (!id) { |
| 112 | + return; |
| 113 | + } |
| 114 | + |
| 115 | + const secretRef = displayState.botAppSecretName ? `\${secrets.${displayState.botAppSecretName}}` : ''; |
| 116 | + |
| 117 | + try { |
| 118 | + await updateAIAgent( |
| 119 | + create(UpdateAIAgentRequestSchema, { |
| 120 | + id, |
| 121 | + aiAgent: create(AIAgentUpdateSchema, { |
| 122 | + teamsBridge: create(AIAgentTeamsBridgeSchema, { |
| 123 | + enabled: displayState.enabled, |
| 124 | + botAppId: displayState.botAppId, |
| 125 | + botTenantId: displayState.botTenantId, |
| 126 | + botAppSecretRef: secretRef || undefined, |
| 127 | + }), |
| 128 | + }), |
| 129 | + updateMask: create(FieldMaskSchema, { |
| 130 | + paths: [ |
| 131 | + 'teams_bridge.enabled', |
| 132 | + 'teams_bridge.bot_app_id', |
| 133 | + 'teams_bridge.bot_tenant_id', |
| 134 | + 'teams_bridge.bot_app_secret_ref', |
| 135 | + ], |
| 136 | + }), |
| 137 | + }), |
| 138 | + { |
| 139 | + onSuccess: () => { |
| 140 | + toast.success('Teams integration updated'); |
| 141 | + setIsEditing(false); |
| 142 | + setEditedState(null); |
| 143 | + }, |
| 144 | + onError: (error) => { |
| 145 | + toast.error(formatToastErrorMessageGRPC({ error, action: 'update', entity: 'Teams integration' })); |
| 146 | + }, |
| 147 | + } |
| 148 | + ); |
| 149 | + } catch { |
| 150 | + // Error already handled |
| 151 | + } |
| 152 | + }; |
| 153 | + |
| 154 | + const handleCancel = () => { |
| 155 | + setIsEditing(false); |
| 156 | + setEditedState(null); |
| 157 | + }; |
| 158 | + |
| 159 | + const messagingEndpointUrl = agent.teamsBridge?.enabled && agent.url ? getMessagingEndpointUrl(agent.url, id) : ''; |
| 160 | + |
| 161 | + return ( |
| 162 | + <div className="space-y-4"> |
| 163 | + <Card className="px-0 py-0" size="full"> |
| 164 | + <CardHeader className="flex flex-row items-center justify-between border-b p-4 dark:border-border [.border-b]:pb-4"> |
| 165 | + <div className="space-y-1"> |
| 166 | + <CardTitle> |
| 167 | + <Text className="font-semibold">Microsoft Teams</Text> |
| 168 | + </CardTitle> |
| 169 | + <Text className="text-muted-foreground text-sm"> |
| 170 | + Connect this agent to Microsoft Teams to enable conversational interactions through a Teams bot. |
| 171 | + </Text> |
| 172 | + </div> |
| 173 | + <div className="flex gap-2"> |
| 174 | + {isEditing ? ( |
| 175 | + <> |
| 176 | + <Button disabled={isUpdatePending} onClick={handleSave} variant="primary"> |
| 177 | + <Save className="h-4 w-4" /> |
| 178 | + {isUpdatePending ? 'Saving...' : 'Save Changes'} |
| 179 | + </Button> |
| 180 | + <Button onClick={handleCancel} variant="outline"> |
| 181 | + Cancel |
| 182 | + </Button> |
| 183 | + </> |
| 184 | + ) : ( |
| 185 | + <Button onClick={() => setIsEditing(true)} variant="primary"> |
| 186 | + <Edit className="h-4 w-4" /> |
| 187 | + Edit Configuration |
| 188 | + </Button> |
| 189 | + )} |
| 190 | + </div> |
| 191 | + </CardHeader> |
| 192 | + <CardContent className="space-y-6 px-4 pb-4"> |
| 193 | + {/* Enable toggle */} |
| 194 | + <div className="flex items-center justify-between"> |
| 195 | + <div className="space-y-0.5"> |
| 196 | + <Label htmlFor="teams-enabled">Enable Teams Integration</Label> |
| 197 | + <Text className="text-muted-foreground text-sm">Activate the Microsoft Teams bridge for this agent</Text> |
| 198 | + </div> |
| 199 | + {isEditing ? ( |
| 200 | + <Switch |
| 201 | + checked={displayState.enabled} |
| 202 | + id="teams-enabled" |
| 203 | + onCheckedChange={(checked) => updateField({ enabled: checked })} |
| 204 | + /> |
| 205 | + ) : ( |
| 206 | + <Text className="font-medium text-sm">{displayState.enabled ? 'Enabled' : 'Disabled'}</Text> |
| 207 | + )} |
| 208 | + </div> |
| 209 | + |
| 210 | + {/* Bot configuration fields */} |
| 211 | + <div className="grid gap-4 md:grid-cols-2"> |
| 212 | + <div className="space-y-2"> |
| 213 | + <Label htmlFor="teams-bot-app-id">Application (client) ID</Label> |
| 214 | + {isEditing ? ( |
| 215 | + <Input |
| 216 | + id="teams-bot-app-id" |
| 217 | + onChange={(e) => updateField({ botAppId: e.target.value })} |
| 218 | + placeholder="e.g., 12345678-abcd-efgh-ijkl-123456789012" |
| 219 | + value={displayState.botAppId} |
| 220 | + /> |
| 221 | + ) : ( |
| 222 | + <div className="flex h-10 items-center rounded-md border border-gray-200 bg-gray-50 px-3 py-2"> |
| 223 | + <Text className="truncate">{displayState.botAppId || '-'}</Text> |
| 224 | + </div> |
| 225 | + )} |
| 226 | + </div> |
| 227 | + |
| 228 | + <div className="space-y-2"> |
| 229 | + <Label htmlFor="teams-bot-tenant-id">Tenant ID</Label> |
| 230 | + {isEditing ? ( |
| 231 | + <Input |
| 232 | + id="teams-bot-tenant-id" |
| 233 | + onChange={(e) => updateField({ botTenantId: e.target.value })} |
| 234 | + placeholder="e.g., 12345678-abcd-efgh-ijkl-123456789012" |
| 235 | + value={displayState.botTenantId} |
| 236 | + /> |
| 237 | + ) : ( |
| 238 | + <div className="flex h-10 items-center rounded-md border border-gray-200 bg-gray-50 px-3 py-2"> |
| 239 | + <Text className="truncate">{displayState.botTenantId || '-'}</Text> |
| 240 | + </div> |
| 241 | + )} |
| 242 | + </div> |
| 243 | + </div> |
| 244 | + |
| 245 | + {/* Client Secret */} |
| 246 | + <div className="space-y-2"> |
| 247 | + <Label>Client Secret</Label> |
| 248 | + {isEditing ? ( |
| 249 | + <div className="[&>div]:flex-col [&>div]:items-stretch [&>div]:gap-2"> |
| 250 | + <SecretSelector |
| 251 | + availableSecrets={availableSecrets} |
| 252 | + customText={TEAMS_SECRET_TEXT} |
| 253 | + onChange={(value) => updateField({ botAppSecretName: value })} |
| 254 | + placeholder="Select from secrets store or create new" |
| 255 | + scopes={[Scope.AI_AGENT]} |
| 256 | + value={displayState.botAppSecretName} |
| 257 | + /> |
| 258 | + </div> |
| 259 | + ) : ( |
| 260 | + <div className="flex h-10 items-center rounded-md border border-gray-200 bg-gray-50 px-3 py-2"> |
| 261 | + <Text className="truncate">{displayState.botAppSecretName || '-'}</Text> |
| 262 | + </div> |
| 263 | + )} |
| 264 | + </div> |
| 265 | + |
| 266 | + {/* Messaging endpoint URL - shown when integration is saved and enabled */} |
| 267 | + {Boolean(agent.teamsBridge?.enabled && messagingEndpointUrl) && ( |
| 268 | + <div className="space-y-2"> |
| 269 | + <Label>Messaging Endpoint</Label> |
| 270 | + <Text className="text-muted-foreground text-sm"> |
| 271 | + Configure this URL as the messaging endpoint in your Azure Bot registration. |
| 272 | + </Text> |
| 273 | + <DynamicCodeBlock code={messagingEndpointUrl} lang="text" /> |
| 274 | + </div> |
| 275 | + )} |
| 276 | + </CardContent> |
| 277 | + </Card> |
| 278 | + </div> |
| 279 | + ); |
| 280 | +}; |
0 commit comments