| @@ -1,6 +1,6 @@ |
| 1 | "use client"; | 1 | "use client"; |
| 2 | | 2 | |
| 3 | import { useRef, useState } from "react"; | 3 | import { useEffect, useMemo, useState } from "react"; |
| 4 | import { | 4 | import { |
| 5 | Wrench, | 5 | Wrench, |
| 6 | Plus, | 6 | Plus, |
| @@ -11,7 +11,6 @@ import { · 4 unmodified lines |
| 11 | AlertTriangle, | 11 | AlertTriangle, |
| 12 | KeyRound, | 12 | KeyRound, |
| 13 | Pencil, | 13 | Pencil, |
| 14 | Loader2, | | |
| 15 | } from "lucide-react"; | 14 | } from "lucide-react"; |
| 16 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; | 15 | import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; |
| 17 | import { Button } from "@/components/ui/button"; | 16 | import { Button } from "@/components/ui/button"; |
| @@ -52,6 +51,7 @@ import type { · 34 unmodified lines |
| 52 | ToolSourceRecord, | 51 | ToolSourceRecord, |
| 53 | CredentialRecord, | 52 | CredentialRecord, |
| 54 | CredentialScope, | 53 | CredentialScope, |
| | 54 | SourceAuthProfile, |
| 55 | OpenApiSourceQuality, | 55 | OpenApiSourceQuality, |
| 56 | } from "@/lib/types"; | 56 | } from "@/lib/types"; |
| 57 | import { parse as parseDomain } from "tldts"; | 57 | import { parse as parseDomain } from "tldts"; |
| @@ -69,15 +69,108 @@ interface CatalogCollectionItem { · 11 unmodified lines |
| 69 | logoUrl?: string; | 69 | logoUrl?: string; |
| 70 | categories?: string; | 70 | categories?: string; |
| 71 | version?: string; | 71 | version?: string; |
| | 72 | rank: number; |
| | 73 | addedAt: string; |
| 72 | } | 74 | } |
| 73 | | 75 | |
| 74 | interface CatalogCollectionsResponse { | 76 | const HARD_CODED_CATALOG_ITEMS: CatalogCollectionItem[] = [ |
| 75 | items?: CatalogCollectionItem[]; | 77 | { |
| 76 | totalCount?: number; | 78 | id: "github-rest", |
| 77 | hasMore?: boolean; | 79 | name: "GitHub REST API", |
| 78 | error?: string; | 80 | summary: "Manage repositories, pull requests, issues, and org settings.", |
| 79 | detail?: string; | 81 | specUrl: "https://raw.githubusercontent.com/github/rest-api-description/main/descriptions/api.github.com/api.github.com.yaml", |
| 80 | } | 82 | originUrl: "https://docs.github.com/en/rest", |
| | 83 | providerName: "GitHub", |
| | 84 | categories: "developer-tools", |
| | 85 | version: "latest", |
| | 86 | rank: 1, |
| | 87 | addedAt: "2026-01-10", |
| | 88 | }, |
| | 89 | { |
| | 90 | id: "stripe-api", |
| | 91 | name: "Stripe API", |
| | 92 | summary: "Create payments, manage customers, and handle billing workflows.", |
| | 93 | specUrl: "https://raw.githubusercontent.com/stripe/openapi/master/openapi/spec3.json", |
| | 94 | originUrl: "https://docs.stripe.com/api", |
| | 95 | providerName: "Stripe", |
| | 96 | categories: "payments", |
| | 97 | version: "2026-01", |
| | 98 | rank: 2, |
| | 99 | addedAt: "2026-01-08", |
| | 100 | }, |
| | 101 | { |
| | 102 | id: "openai-api", |
| | 103 | name: "OpenAI API", |
| | 104 | summary: "Generate text, run reasoning models, and process multimodal inputs.", |
| | 105 | specUrl: "https://app.stainless.com/api/spec/documented/openai/openapi.documented.yml", |
| | 106 | originUrl: "https://platform.openai.com/docs/api-reference", |
| | 107 | providerName: "OpenAI", |
| | 108 | categories: "ai", |
| | 109 | version: "latest", |
| | 110 | rank: 3, |
| | 111 | addedAt: "2026-01-06", |
| | 112 | }, |
| | 113 | { |
| | 114 | id: "cloudflare-api", |
| | 115 | name: "Cloudflare API", |
| | 116 | summary: "Control zones, DNS records, workers, and edge configuration.", |
| | 117 | specUrl: "https://raw.githubusercontent.com/cloudflare/api-schemas/main/openapi.yaml", |
| | 118 | originUrl: "https://api.cloudflare.com/", |
| | 119 | providerName: "Cloudflare", |
| | 120 | categories: "infrastructure", |
| | 121 | version: "latest", |
| | 122 | rank: 4, |
| | 123 | addedAt: "2026-01-04", |
| | 124 | }, |
| | 125 | { |
| | 126 | id: "vercel-api", |
| | 127 | name: "Vercel API", |
| | 128 | summary: "Manage deployments, projects, domains, and team resources.", |
| | 129 | specUrl: "https://openapi.vercel.sh", |
| | 130 | originUrl: "https://vercel.com/docs/rest-api", |
| | 131 | providerName: "Vercel", |
| | 132 | categories: "developer-tools", |
| | 133 | version: "latest", |
| | 134 | rank: 5, |
| | 135 | addedAt: "2025-12-18", |
| | 136 | }, |
| | 137 | { |
| | 138 | id: "slack-api", |
| | 139 | name: "Slack API", |
| | 140 | summary: "Work with channels, messages, users, and workspace automation.", |
| | 141 | specUrl: "https://api.slack.com/specs/openapi/v2/slack_web.json", |
| | 142 | originUrl: "https://api.slack.com/web", |
| | 143 | providerName: "Slack", |
| | 144 | categories: "communications", |
| | 145 | version: "v2", |
| | 146 | rank: 6, |
| | 147 | addedAt: "2025-12-10", |
| | 148 | }, |
| | 149 | { |
| | 150 | id: "sentry-api", |
| | 151 | name: "Sentry API", |
| | 152 | summary: "Query issues, releases, projects, and alerting configuration.", |
| | 153 | specUrl: "https://raw.githubusercontent.com/getsentry/sentry-api-schema/refs/heads/main/openapi-derefed.json", |
| | 154 | originUrl: "https://docs.sentry.io/api/", |
| | 155 | providerName: "Sentry", |
| | 156 | categories: "observability", |
| | 157 | version: "latest", |
| | 158 | rank: 7, |
| | 159 | addedAt: "2025-11-30", |
| | 160 | }, |
| | 161 | { |
| | 162 | id: "jira-cloud-api", |
| | 163 | name: "Jira Cloud Platform", |
| | 164 | summary: "Manage projects, issues, workflows, and Jira metadata.", |
| | 165 | specUrl: "https://developer.atlassian.com/cloud/jira/platform/swagger-v3.v3.json", |
| | 166 | originUrl: "https://developer.atlassian.com/cloud/jira/platform/rest/v3/intro/", |
| | 167 | providerName: "Atlassian", |
| | 168 | categories: "project-management", |
| | 169 | version: "v3", |
| | 170 | rank: 8, |
| | 171 | addedAt: "2025-11-15", |
| | 172 | }, |
| | 173 | ]; |
| 81 | | 174 | |
| 82 | /** Derive a favicon URL from any URL string via Google's favicon service. */ | 175 | /** Derive a favicon URL from any URL string via Google's favicon service. */ |
| 83 | function faviconForUrl(url: string | undefined | null): string | null { | 176 | function faviconForUrl(url: string | undefined | null): string | null { |
| @@ -141,45 +234,94 @@ function sourceForCredentialKey(sources: ToolSourceRecord[], sourceKey: string): · 57 unmodified lines |
| 141 | return sources.find((source) => source.id === sourceId) ?? null; | 234 | return sources.find((source) => source.id === sourceId) ?? null; |
| 142 | } | 235 | } |
| 143 | | 236 | |
| 144 | type SourceAuthType = "none" | "bearer" | "apiKey" | "basic"; | 237 | type SourceAuthType = "none" | "bearer" | "apiKey" | "basic" | "mixed"; |
| 145 | type SourceAuthMode = "workspace" | "actor"; | 238 | type SourceAuthMode = "workspace" | "actor"; |
| 146 | | 239 | |
| 147 | function readSourceAuth(source: ToolSourceRecord): { | 240 | function normalizeSourceAuthProfile(profile: SourceAuthProfile | undefined): { |
| | 241 | type: SourceAuthType; |
| | 242 | mode?: SourceAuthMode; |
| | 243 | header?: string; |
| | 244 | inferred?: boolean; |
| | 245 | } { |
| | 246 | if (!profile) { |
| | 247 | return { type: "none" }; |
| | 248 | } |
| | 249 | |
| | 250 | const type = profile.type === "mixed" |
| | 251 | ? "mixed" |
| | 252 | : profile.type === "basic" |
| | 253 | ? "basic" |
| | 254 | : profile.type === "apiKey" |
| | 255 | ? "apiKey" |
| | 256 | : profile.type === "bearer" |
| | 257 | ? "bearer" |
| | 258 | : "none"; |
| | 259 | |
| | 260 | const mode = profile.mode === "actor" ? "actor" : profile.mode === "workspace" ? "workspace" : undefined; |
| | 261 | const header = typeof profile.header === "string" && profile.header.trim().length > 0 |
| | 262 | ? profile.header.trim() |
| | 263 | : undefined; |
| | 264 | |
| | 265 | return { |
| | 266 | type, |
| | 267 | ...(mode ? { mode } : {}), |
| | 268 | ...(header ? { header } : {}), |
| | 269 | inferred: Boolean(profile.inferred), |
| | 270 | }; |
| | 271 | } |
| | 272 | |
| | 273 | function readSourceAuth( |
| | 274 | source: ToolSourceRecord, |
| | 275 | inferredProfile?: SourceAuthProfile, |
| | 276 | ): { |
| 148 | type: SourceAuthType; | 277 | type: SourceAuthType; |
| 149 | mode?: SourceAuthMode; | 278 | mode?: SourceAuthMode; |
| 150 | header?: string; | 279 | header?: string; |
| | 280 | inferred?: boolean; |
| 151 | } { | 281 | } { |
| 152 | if (source.type !== "openapi" && source.type !== "graphql") { | 282 | if (source.type !== "openapi" && source.type !== "graphql") { |
| 153 | return { type: "none" }; | 283 | return { type: "none" }; |
| 154 | } | 284 | } |
| 155 | | 285 | |
| | 286 | const inferred = normalizeSourceAuthProfile(inferredProfile); |
| | 287 | |
| 156 | const auth = source.config.auth as Record<string, unknown> | undefined; | 288 | const auth = source.config.auth as Record<string, unknown> | undefined; |
| 157 | const type = | 289 | const type = |
| 158 | auth && typeof auth.type === "string" && ["none", "bearer", "apiKey", "basic"].includes(auth.type) | 290 | auth && typeof auth.type === "string" && ["none", "bearer", "apiKey", "basic", "mixed"].includes(auth.type) |
| 159 | ? (auth.type as SourceAuthType) | 291 | ? (auth.type as SourceAuthType) |
| 160 | : "none"; | 292 | : inferred.type; |
| 161 | | 293 | |
| 162 | const mode = | 294 | const mode = |
| 163 | auth && typeof auth.mode === "string" && (auth.mode === "workspace" || auth.mode === "actor") | 295 | auth && typeof auth.mode === "string" && (auth.mode === "workspace" || auth.mode === "actor") |
| 164 | ? (auth.mode as SourceAuthMode) | 296 | ? (auth.mode as SourceAuthMode) |
| 165 | : undefined; | 297 | : inferred.mode; |
| 166 | | 298 | |
| 167 | const header = auth && typeof auth.header === "string" && auth.header.trim().length > 0 | 299 | const header = auth && typeof auth.header === "string" && auth.header.trim().length > 0 |
| 168 | ? auth.header.trim() | 300 | ? auth.header.trim() |
| 169 | : undefined; | 301 | : inferred.header; |
| 170 | | 302 | |
| 171 | return { | 303 | return { |
| 172 | type, | 304 | type, |
| 173 | ...(mode ? { mode } : {}), | 305 | ...(mode ? { mode } : {}), |
| 174 | ...(header ? { header } : {}), | 306 | ...(header ? { header } : {}), |
| | 307 | inferred: auth?.type === undefined ? inferred.inferred : false, |
| 175 | }; | 308 | }; |
| 176 | } | 309 | } |
| 177 | | 310 | |
| 178 | function formatSourceAuthBadge(source: ToolSourceRecord): string | null { | 311 | function formatSourceAuthBadge(source: ToolSourceRecord, inferredProfile?: SourceAuthProfile): string | null { |
| 179 | const auth = readSourceAuth(source); | 312 | const auth = readSourceAuth(source, inferredProfile); |
| 180 | if (auth.type === "none") return null; | 313 | if (auth.type === "none") return null; |
| | 314 | if (auth.type === "mixed") return "Mixed auth"; |
| 181 | const mode = auth.mode ?? "workspace"; | 315 | const mode = auth.mode ?? "workspace"; |
| 182 | return `${auth.type}:${mode}`; | 316 | const authLabel = |
| | 317 | auth.type === "apiKey" |
| | 318 | ? "API Key" |
| | 319 | : auth.type === "bearer" |
| | 320 | ? "Bearer" |
| | 321 | : auth.type === "basic" |
| | 322 | ? "Basic" |
| | 323 | : "Auth"; |
| | 324 | return `${authLabel} · ${mode === "actor" ? "user" : "workspace"}`; |
| 183 | } | 325 | } |
| 184 | | 326 | |
| 185 | function credentialStatsForSource(source: ToolSourceRecord, credentials: CredentialRecord[]): { | 327 | function credentialStatsForSource(source: ToolSourceRecord, credentials: CredentialRecord[]): { |
| @@ -206,14 +348,62 @@ function formatQualityPercent(value: number): string { · 20 unmodified lines |
| 206 | return `${Math.round(value * 100)}%`; | 348 | return `${Math.round(value * 100)}%`; |
| 207 | } | 349 | } |
| 208 | | 350 | |
| 209 | function qualityBadgeClass(quality: OpenApiSourceQuality): string { | 351 | function qualityToneClass(quality: OpenApiSourceQuality): string { |
| 210 | if (quality.overallQuality >= 0.95) { | 352 | if (quality.overallQuality >= 0.95) { |
| 211 | return "text-terminal-green border-terminal-green/30"; | 353 | return "text-terminal-green"; |
| 212 | } | 354 | } |
| 213 | if (quality.overallQuality >= 0.85) { | 355 | if (quality.overallQuality >= 0.85) { |
| 214 | return "text-terminal-amber border-terminal-amber/30"; | 356 | return "text-terminal-amber"; |
| | 357 | } |
| | 358 | return "text-terminal-red"; |
| | 359 | } |
| | 360 | |
| | 361 | function qualitySummaryLabel(quality: OpenApiSourceQuality): string { |
| | 362 | if (quality.overallQuality >= 0.95) { |
| | 363 | return "strong typing"; |
| | 364 | } |
| | 365 | if (quality.overallQuality >= 0.85) { |
| | 366 | return "mostly typed"; |
| | 367 | } |
| | 368 | return "needs type cleanup"; |
| | 369 | } |
| | 370 | |
| | 371 | function displaySourceName(name: string): string { |
| | 372 | const parts = name.split(/[-_.]+/).filter(Boolean); |
| | 373 | if (parts.length === 0) return name; |
| | 374 | |
| | 375 | const deduped = parts.filter((part, index, all) => { |
| | 376 | if (index === 0) return true; |
| | 377 | return part.toLowerCase() !== all[index - 1]?.toLowerCase(); |
| | 378 | }); |
| | 379 | |
| | 380 | const tokenMap: Record<string, string> = { |
| | 381 | api: "API", |
| | 382 | oauth: "OAuth", |
| | 383 | graphql: "GraphQL", |
| | 384 | mcp: "MCP", |
| | 385 | github: "GitHub", |
| | 386 | }; |
| | 387 | |
| | 388 | return deduped |
| | 389 | .map((token) => { |
| | 390 | const lower = token.toLowerCase(); |
| | 391 | if (tokenMap[lower]) return tokenMap[lower]; |
| | 392 | return `${lower[0]?.toUpperCase() ?? ""}${lower.slice(1)}`; |
| | 393 | }) |
| | 394 | .join(" "); |
| | 395 | } |
| | 396 | |
| | 397 | function compactEndpointLabel(source: ToolSourceRecord): string { |
| | 398 | const endpoint = sourceEndpointLabel(source); |
| | 399 | if (endpoint.startsWith("catalog:")) return endpoint; |
| | 400 | try { |
| | 401 | const parsed = new URL(endpoint); |
| | 402 | const path = parsed.pathname === "/" ? "" : parsed.pathname; |
| | 403 | return `${parsed.hostname}${path}`; |
| | 404 | } catch { |
| | 405 | return endpoint; |
| 215 | } | 406 | } |
| 216 | return "text-terminal-red border-terminal-red/30"; | | |
| 217 | } | 407 | } |
| 218 | | 408 | |
| 219 | // ── Add Source Dialog ── | 409 | // ── Add Source Dialog ── |
| @@ -284,8 +474,10 @@ function catalogSourceName(item: CatalogCollectionItem): string { · 64 unmodified lines |
| 284 | | 474 | |
| 285 | function AddSourceDialog({ | 475 | function AddSourceDialog({ |
| 286 | existingSourceNames, | 476 | existingSourceNames, |
| | 477 | onSourceAdded, |
| 287 | }: { | 478 | }: { |
| 288 | existingSourceNames: Set<string>; | 479 | existingSourceNames: Set<string>; |
| | 480 | onSourceAdded?: (source: ToolSourceRecord) => void; |
| 289 | }) { | 481 | }) { |
| 290 | const { context } = useSession(); | 482 | const { context } = useSession(); |
| 291 | const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource); | 483 | const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource); |
| @@ -302,18 +494,27 @@ function AddSourceDialog({ · 10 unmodified lines |
| 302 | const [locallyReservedNames, setLocallyReservedNames] = useState<string[]>([]); | 494 | const [locallyReservedNames, setLocallyReservedNames] = useState<string[]>([]); |
| 303 | const [catalogQuery, setCatalogQuery] = useState(""); | 495 | const [catalogQuery, setCatalogQuery] = useState(""); |
| 304 | const [catalogSort, setCatalogSort] = useState<"popular" | "recent">("popular"); | 496 | const [catalogSort, setCatalogSort] = useState<"popular" | "recent">("popular"); |
| 305 | const [catalogItems, setCatalogItems] = useState<CatalogCollectionItem[]>([]); | | |
| 306 | const [catalogOffset, setCatalogOffset] = useState(0); | | |
| 307 | const [catalogHasMore, setCatalogHasMore] = useState(true); | | |
| 308 | const [catalogTotalCount, setCatalogTotalCount] = useState<number | null>(null); | | |
| 309 | const [catalogLoading, setCatalogLoading] = useState(false); | | |
| 310 | const [catalogLoadingMore, setCatalogLoadingMore] = useState(false); | | |
| 311 | const [catalogError, setCatalogError] = useState<string | null>(null); | | |
| 312 | const [addingCatalogId, setAddingCatalogId] = useState<string | null>(null); | 497 | const [addingCatalogId, setAddingCatalogId] = useState<string | null>(null); |
| 313 | const catalogRequestIdRef = useRef(0); | | |
| 314 | const catalogInFlightRef = useRef(false); | | |
| 315 | | 498 | |
| 316 | const CATALOG_PAGE_SIZE = 20; | 499 | const visibleCatalogItems = useMemo(() => { |
| | 500 | const query = catalogQuery.trim().toLowerCase(); |
| | 501 | const filtered = HARD_CODED_CATALOG_ITEMS.filter((item) => { |
| | 502 | if (!query) return true; |
| | 503 | return [ |
| | 504 | item.name, |
| | 505 | item.providerName, |
| | 506 | item.summary, |
| | 507 | item.categories ?? "", |
| | 508 | ].some((value) => value.toLowerCase().includes(query)); |
| | 509 | }); |
| | 510 | |
| | 511 | return [...filtered].sort((a, b) => { |
| | 512 | if (catalogSort === "recent") { |
| | 513 | return new Date(b.addedAt).getTime() - new Date(a.addedAt).getTime(); |
| | 514 | } |
| | 515 | return a.rank - b.rank; |
| | 516 | }); |
| | 517 | }, [catalogQuery, catalogSort]); |
| 317 | | 518 | |
| 318 | const getTakenSourceNames = () => new Set([...existingSourceNames, ...locallyReservedNames]); | 519 | const getTakenSourceNames = () => new Set([...existingSourceNames, ...locallyReservedNames]); |
| 319 | | 520 | |
| @@ -343,8 +544,6 @@ function AddSourceDialog({ · 23 unmodified lines |
| 343 | }; | 544 | }; |
| 344 | | 545 | |
| 345 | const resetDialogState = () => { | 546 | const resetDialogState = () => { |
| 346 | catalogRequestIdRef.current += 1; | | |
| 347 | catalogInFlightRef.current = false; | | |
| 348 | setView("catalog"); | 547 | setView("catalog"); |
| 349 | setType("mcp"); | 548 | setType("mcp"); |
| 350 | setName(""); | 549 | setName(""); |
| @@ -356,117 +555,14 @@ function AddSourceDialog({ · 5 unmodified lines |
| 356 | setLocallyReservedNames([]); | 555 | setLocallyReservedNames([]); |
| 357 | setCatalogQuery(""); | 556 | setCatalogQuery(""); |
| 358 | setCatalogSort("popular"); | 557 | setCatalogSort("popular"); |
| 359 | setCatalogItems([]); | | |
| 360 | setCatalogOffset(0); | | |
| 361 | setCatalogHasMore(true); | | |
| 362 | setCatalogTotalCount(null); | | |
| 363 | setCatalogLoading(false); | | |
| 364 | setCatalogLoadingMore(false); | | |
| 365 | setCatalogError(null); | | |
| 366 | setAddingCatalogId(null); | 558 | setAddingCatalogId(null); |
| 367 | }; | 559 | }; |
| 368 | | 560 | |
| 369 | const loadCatalogPage = async ({ | | |
| 370 | mode, | | |
| 371 | query, | | |
| 372 | sort, | | |
| 373 | }: { | | |
| 374 | mode: "reset" | "next"; | | |
| 375 | query?: string; | | |
| 376 | sort?: "popular" | "recent"; | | |
| 377 | }) => { | | |
| 378 | const resolvedQuery = (query ?? catalogQuery).trim(); | | |
| 379 | const resolvedSort = sort ?? catalogSort; | | |
| 380 | const nextOffset = mode === "reset" ? 0 : catalogOffset; | | |
| 381 | | | |
| 382 | if (mode === "next") { | | |
| 383 | if (catalogLoading || catalogLoadingMore || catalogInFlightRef.current || !catalogHasMore) { | | |
| 384 | return; | | |
| 385 | } | | |
| 386 | setCatalogLoadingMore(true); | | |
| 387 | } else { | | |
| 388 | setCatalogLoading(true); | | |
| 389 | setCatalogLoadingMore(false); | | |
| 390 | setCatalogError(null); | | |
| 391 | } | | |
| 392 | | | |
| 393 | const requestId = catalogRequestIdRef.current + 1; | | |
| 394 | catalogRequestIdRef.current = requestId; | | |
| 395 | catalogInFlightRef.current = true; | | |
| 396 | | | |
| 397 | try { | | |
| 398 | const params = new URLSearchParams({ | | |
| 399 | sort: resolvedSort, | | |
| 400 | limit: String(CATALOG_PAGE_SIZE), | | |
| 401 | offset: String(nextOffset), | | |
| 402 | }); | | |
| 403 | if (resolvedQuery.length > 0) { | | |
| 404 | params.set("q", resolvedQuery); | | |
| 405 | } | | |
| 406 | | | |
| 407 | const catalogBase = process.env.NEXT_PUBLIC_SOURCES_URL ?? "http://127.0.0.1:4343"; | | |
| 408 | const response = await fetch(`${catalogBase}/collections?${params.toString()}`, { | | |
| 409 | cache: "no-store", | | |
| 410 | }); | | |
| 411 | const body = await response.json() as CatalogCollectionsResponse; | | |
| 412 | | | |
| 413 | if (requestId !== catalogRequestIdRef.current) { | | |
| 414 | return; | | |
| 415 | } | | |
| 416 | | | |
| 417 | if (!response.ok) { | | |
| 418 | throw new Error(body.error || "Failed to load API catalog"); | | |
| 419 | } | | |
| 420 | | | |
| 421 | const nextItems = Array.isArray(body.items) ? body.items : []; | | |
| 422 | setCatalogItems((current) => { | | |
| 423 | if (mode === "reset") { | | |
| 424 | return nextItems; | | |
| 425 | } | | |
| 426 | | | |
| 427 | const merged = [...current]; | | |
| 428 | const seen = new Set(current.map((entry) => entry.id)); | | |
| 429 | for (const item of nextItems) { | | |
| 430 | if (seen.has(item.id)) continue; | | |
| 431 | seen.add(item.id); | | |
| 432 | merged.push(item); | | |
| 433 | } | | |
| 434 | return merged; | | |
| 435 | }); | | |
| 436 | setCatalogOffset(nextOffset + nextItems.length); | | |
| 437 | setCatalogHasMore(typeof body.hasMore === "boolean" ? body.hasMore : nextItems.length >= CATALOG_PAGE_SIZE); | | |
| 438 | setCatalogTotalCount(typeof body.totalCount === "number" ? body.totalCount : null); | | |
| 439 | setCatalogError(null); | | |
| 440 | } catch (error) { | | |
| 441 | if (requestId !== catalogRequestIdRef.current) { | | |
| 442 | return; | | |
| 443 | } | | |
| 444 | | | |
| 445 | if (mode === "reset") { | | |
| 446 | setCatalogItems([]); | | |
| 447 | setCatalogOffset(0); | | |
| 448 | } | | |
| 449 | setCatalogHasMore(false); | | |
| 450 | setCatalogTotalCount(null); | | |
| 451 | setCatalogError(error instanceof Error ? error.message : "Failed to load API catalog"); | | |
| 452 | } finally { | | |
| 453 | if (requestId === catalogRequestIdRef.current) { | | |
| 454 | setCatalogLoading(false); | | |
| 455 | setCatalogLoadingMore(false); | | |
| 456 | catalogInFlightRef.current = false; | | |
| 457 | } | | |
| 458 | } | | |
| 459 | }; | | |
| 460 | | | |
| 461 | const handleOpenChange = (isOpen: boolean) => { | 561 | const handleOpenChange = (isOpen: boolean) => { |
| 462 | setOpen(isOpen); | 562 | setOpen(isOpen); |
| 463 | if (!isOpen) { | 563 | if (!isOpen) { |
| 464 | resetDialogState(); | 564 | resetDialogState(); |
| 465 | return; | | |
| 466 | } | 565 | } |
| 467 | | | |
| 468 | resetDialogState(); | | |
| 469 | void loadCatalogPage({ mode: "reset", query: "", sort: "popular" }); | | |
| 470 | }; | 566 | }; |
| 471 | | 567 | |
| 472 | const addSource = async ( | 568 | const addSource = async ( |
| @@ -475,13 +571,14 @@ function AddSourceDialog({ · 2 unmodified lines |
| 475 | config: Record<string, unknown>, | 571 | config: Record<string, unknown>, |
| 476 | ) => { | 572 | ) => { |
| 477 | if (!context) return; | 573 | if (!context) return; |
| 478 | await upsertToolSource({ | 574 | const created = await upsertToolSource({ |
| 479 | workspaceId: context.workspaceId, | 575 | workspaceId: context.workspaceId, |
| 480 | sessionId: context.sessionId, | 576 | sessionId: context.sessionId, |
| 481 | name: sourceName, | 577 | name: sourceName, |
| 482 | type: sourceType, | 578 | type: sourceType, |
| 483 | config, | 579 | config, |
| 484 | }); | 580 | }); |
| | 581 | onSourceAdded?.(created as ToolSourceRecord); |
| 485 | toast.success(`Source "${sourceName}" added — loading tools…`); | 582 | toast.success(`Source "${sourceName}" added — loading tools…`); |
| 486 | }; | 583 | }; |
| 487 | | 584 | |
| @@ -563,20 +660,10 @@ function AddSourceDialog({ · 75 unmodified lines |
| 563 | onChange={(event) => setCatalogQuery(event.target.value)} | 660 | onChange={(event) => setCatalogQuery(event.target.value)} |
| 564 | placeholder="Search APIs" | 661 | placeholder="Search APIs" |
| 565 | className="h-8 text-xs font-mono bg-background flex-1 min-w-[150px]" | 662 | className="h-8 text-xs font-mono bg-background flex-1 min-w-[150px]" |
| 566 | onKeyDown={(event) => { | | |
| 567 | if (event.key === "Enter") { | | |
| 568 | event.preventDefault(); | | |
| 569 | void loadCatalogPage({ mode: "reset" }); | | |
| 570 | } | | |
| 571 | }} | | |
| 572 | /> | 663 | /> |
| 573 | <Select | 664 | <Select |
| 574 | value={catalogSort} | 665 | value={catalogSort} |
| 575 | onValueChange={(value) => { | 666 | onValueChange={(value) => setCatalogSort(value as "popular" | "recent")} |
| 576 | const nextSort = value as "popular" | "recent"; | | |
| 577 | setCatalogSort(nextSort); | | |
| 578 | void loadCatalogPage({ mode: "reset", sort: nextSort }); | | |
| 579 | }} | | |
| 580 | > | 667 | > |
| 581 | <SelectTrigger className="h-8 w-[105px] text-xs bg-background shrink-0"> | 668 | <SelectTrigger className="h-8 w-[105px] text-xs bg-background shrink-0"> |
| 582 | <SelectValue /> | 669 | <SelectValue /> |
| @@ -586,35 +673,15 @@ function AddSourceDialog({ · 3 unmodified lines |
| 586 | <SelectItem value="recent" className="text-xs">Recent</SelectItem> | 673 | <SelectItem value="recent" className="text-xs">Recent</SelectItem> |
| 587 | </SelectContent> | 674 | </SelectContent> |
| 588 | </Select> | 675 | </Select> |
| 589 | <Button | | |
| 590 | type="button" | | |
| 591 | variant="outline" | | |
| 592 | size="sm" | | |
| 593 | className="h-8 px-3 text-xs shrink-0" | | |
| 594 | onClick={() => void loadCatalogPage({ mode: "reset" })} | | |
| 595 | disabled={catalogLoading} | | |
| 596 | > | | |
| 597 | {catalogLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : "Search"} | | |
| 598 | </Button> | | |
| 599 | </div> | 676 | </div> |
| 600 | | 677 | |
| 601 | <p className="text-[11px] text-muted-foreground"> | 678 | <p className="text-[11px] text-muted-foreground"> |
| 602 | Browse API collections and add them as tool sources. | 679 | Browse curated APIs and add them as tool sources. Showing {visibleCatalogItems.length}. |
| 603 | {catalogTotalCount !== null ? ` Found ${catalogTotalCount.toLocaleString()} total.` : ""} | | |
| 604 | </p> | 680 | </p> |
| 605 | | 681 | |
| 606 | <Separator /> | 682 | <Separator /> |
| 607 | | 683 | |
| 608 | <div | 684 | <div className="max-h-80 overflow-y-auto overflow-x-hidden space-y-1 pr-1"> |
| 609 | className="max-h-80 overflow-y-auto overflow-x-hidden space-y-1 pr-1" | | |
| 610 | onScroll={(event) => { | | |
| 611 | const target = event.currentTarget; | | |
| 612 | const nearBottom = target.scrollHeight - target.scrollTop - target.clientHeight < 120; | | |
| 613 | if (nearBottom) { | | |
| 614 | void loadCatalogPage({ mode: "next" }); | | |
| 615 | } | | |
| 616 | }} | | |
| 617 | > | | |
| 618 | <button | 685 | <button |
| 619 | type="button" | 686 | type="button" |
| 620 | onClick={() => setView("custom")} | 687 | onClick={() => setView("custom")} |
| @@ -631,7 +698,7 @@ function AddSourceDialog({ · 10 unmodified lines |
| 631 | </div> | 698 | </div> |
| 632 | </button> | 699 | </button> |
| 633 | | 700 | |
| 634 | {catalogItems.map((item) => ( | 701 | {visibleCatalogItems.map((item) => ( |
| 635 | <div | 702 | <div |
| 636 | key={item.id} | 703 | key={item.id} |
| 637 | className="w-full max-w-full overflow-hidden flex items-start gap-2 px-2 py-2 rounded-md border border-border/50" | 704 | className="w-full max-w-full overflow-hidden flex items-start gap-2 px-2 py-2 rounded-md border border-border/50" |
| @@ -673,30 +740,11 @@ function AddSourceDialog({ · 35 unmodified lines |
| 673 | </div> | 740 | </div> |
| 674 | ))} | 741 | ))} |
| 675 | | 742 | |
| 676 | {catalogLoadingMore && ( | 743 | {visibleCatalogItems.length === 0 && ( |
| 677 | <div className="flex items-center justify-center py-2 text-[11px] text-muted-foreground"> | | |
| 678 | <Loader2 className="h-3.5 w-3.5 animate-spin mr-1.5" /> | | |
| 679 | Loading more... | | |
| 680 | </div> | | |
| 681 | )} | | |
| 682 | | | |
| 683 | {!catalogLoading && !catalogLoadingMore && catalogItems.length === 0 && !catalogError && ( | | |
| 684 | <p className="text-[11px] text-muted-foreground px-1 py-1"> | 744 | <p className="text-[11px] text-muted-foreground px-1 py-1"> |
| 685 | No collections found for this query. | 745 | No collections found for this query. |
| 686 | </p> | 746 | </p> |
| 687 | )} | 747 | )} |
| 688 | | | |
| 689 | {catalogError && ( | | |
| 690 | <p className="text-[11px] text-terminal-red px-1 py-1"> | | |
| 691 | {catalogError} | | |
| 692 | </p> | | |
| 693 | )} | | |
| 694 | | | |
| 695 | {!catalogHasMore && catalogItems.length > 0 && ( | | |
| 696 | <p className="text-[10px] text-muted-foreground/70 px-1 py-1"> | | |
| 697 | End of results. | | |
| 698 | </p> | | |
| 699 | )} | | |
| 700 | </div> | 748 | </div> |
| 701 | </div> | 749 | </div> |
| 702 | ) : ( | 750 | ) : ( |
| @@ -820,24 +868,27 @@ function AddSourceDialog({ · 117 unmodified lines |
| 820 | | 868 | |
| 821 | function ConfigureSourceAuthDialog({ | 869 | function ConfigureSourceAuthDialog({ |
| 822 | source, | 870 | source, |
| | 871 | inferredProfile, |
| 823 | }: { | 872 | }: { |
| 824 | source: ToolSourceRecord; | 873 | source: ToolSourceRecord; |
| | 874 | inferredProfile?: SourceAuthProfile; |
| 825 | }) { | 875 | }) { |
| 826 | const { context } = useSession(); | 876 | const { context } = useSession(); |
| 827 | const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource); | 877 | const upsertToolSource = useMutation(convexApi.workspace.upsertToolSource); |
| 828 | const [open, setOpen] = useState(false); | 878 | const [open, setOpen] = useState(false); |
| 829 | const [saving, setSaving] = useState(false); | 879 | const [saving, setSaving] = useState(false); |
| 830 | | 880 | |
| 831 | const currentAuth = readSourceAuth(source); | 881 | const currentAuth = readSourceAuth(source, inferredProfile); |
| 832 | const [authType, setAuthType] = useState<SourceAuthType>(currentAuth.type); | 882 | const editableInitialAuthType = currentAuth.type === "mixed" ? "bearer" : currentAuth.type; |
| | 883 | const [authType, setAuthType] = useState<Exclude<SourceAuthType, "mixed">>(editableInitialAuthType); |
| 833 | const [authMode, setAuthMode] = useState<SourceAuthMode>(currentAuth.mode ?? "workspace"); | 884 | const [authMode, setAuthMode] = useState<SourceAuthMode>(currentAuth.mode ?? "workspace"); |
| 834 | const [apiKeyHeader, setApiKeyHeader] = useState(currentAuth.header ?? "x-api-key"); | 885 | const [apiKeyHeader, setApiKeyHeader] = useState(currentAuth.header ?? "x-api-key"); |
| 835 | | 886 | |
| 836 | const configurable = source.type === "openapi" || source.type === "graphql"; | 887 | const configurable = source.type === "openapi" || source.type === "graphql"; |
| 837 | | 888 | |
| 838 | const resetFromSource = () => { | 889 | const resetFromSource = () => { |
| 839 | const auth = readSourceAuth(source); | 890 | const auth = readSourceAuth(source, inferredProfile); |
| 840 | setAuthType(auth.type); | 891 | setAuthType(auth.type === "mixed" ? "bearer" : auth.type); |
| 841 | setAuthMode(auth.mode ?? "workspace"); | 892 | setAuthMode(auth.mode ?? "workspace"); |
| 842 | setApiKeyHeader(auth.header ?? "x-api-key"); | 893 | setApiKeyHeader(auth.header ?? "x-api-key"); |
| 843 | }; | 894 | }; |
| @@ -905,7 +956,7 @@ function ConfigureSourceAuthDialog({ · 61 unmodified lines |
| 905 | | 956 | |
| 906 | <div className="space-y-1.5"> | 957 | <div className="space-y-1.5"> |
| 907 | <Label className="text-xs text-muted-foreground">Auth Type</Label> | 958 | <Label className="text-xs text-muted-foreground">Auth Type</Label> |
| 908 | <Select value={authType} onValueChange={(value) => setAuthType(value as SourceAuthType)}> | 959 | <Select value={authType} onValueChange={(value) => setAuthType(value as Exclude<SourceAuthType, "mixed">)}> |
| 909 | <SelectTrigger className="h-8 text-xs bg-background"> | 960 | <SelectTrigger className="h-8 text-xs bg-background"> |
| 910 | <SelectValue /> | 961 | <SelectValue /> |
| 911 | </SelectTrigger> | 962 | </SelectTrigger> |
| @@ -918,6 +969,12 @@ function ConfigureSourceAuthDialog({ · 6 unmodified lines |
| 918 | </Select> | 969 | </Select> |
| 919 | </div> | 970 | </div> |
| 920 | | 971 | |
| | 972 | {currentAuth.inferred && ( |
| | 973 | <p className="text-[11px] text-muted-foreground"> |
| | 974 | Suggested from spec inference. Save to pin an explicit auth config. |
| | 975 | </p> |
| | 976 | )} |
| | 977 | |
| 921 | {authType !== "none" && ( | 978 | {authType !== "none" && ( |
| 922 | <> | 979 | <> |
| 923 | <div className="space-y-1.5"> | 980 | <div className="space-y-1.5"> |
| @@ -946,7 +1003,7 @@ function ConfigureSourceAuthDialog({ · 22 unmodified lines |
| 946 | )} | 1003 | )} |
| 947 | | 1004 | |
| 948 | <p className="text-[11px] text-muted-foreground"> | 1005 | <p className="text-[11px] text-muted-foreground"> |
| 949 | Save this first, then add credentials in the Credentials tab using source key | 1006 | Save this first, then add a connection in the Connections tab using source key |
| 950 | <code className="ml-1">{sourceKeyForSource(source)}</code>. | 1007 | <code className="ml-1">{sourceKeyForSource(source)}</code>. |
| 951 | </p> | 1008 | </p> |
| 952 | </> | 1009 | </> |
| @@ -966,15 +1023,22 @@ function SourceCard({ · 13 unmodified lines |
| 966 | quality, | 1023 | quality, |
| 967 | qualityLoading, | 1024 | qualityLoading, |
| 968 | credentialStats, | 1025 | credentialStats, |
| | 1026 | sourceAuthProfiles, |
| | 1027 | selected = false, |
| | 1028 | onFocusSource, |
| 969 | }: { | 1029 | }: { |
| 970 | source: ToolSourceRecord; | 1030 | source: ToolSourceRecord; |
| 971 | quality?: OpenApiSourceQuality; | 1031 | quality?: OpenApiSourceQuality; |
| 972 | qualityLoading?: boolean; | 1032 | qualityLoading?: boolean; |
| 973 | credentialStats: { workspaceCount: number; actorCount: number }; | 1033 | credentialStats: { workspaceCount: number; actorCount: number }; |
| | 1034 | sourceAuthProfiles: Record<string, SourceAuthProfile>; |
| | 1035 | selected?: boolean; |
| | 1036 | onFocusSource?: (sourceName: string) => void; |
| 974 | }) { | 1037 | }) { |
| 975 | const { context } = useSession(); | 1038 | const { context } = useSession(); |
| 976 | const deleteToolSource = useMutation(convexApi.workspace.deleteToolSource); | 1039 | const deleteToolSource = useMutation(convexApi.workspace.deleteToolSource); |
| 977 | const [deleting, setDeleting] = useState(false); | 1040 | const [deleting, setDeleting] = useState(false); |
| | 1041 | const [detailsOpen, setDetailsOpen] = useState(false); |
| 978 | | 1042 | |
| 979 | const handleDelete = async () => { | 1043 | const handleDelete = async () => { |
| 980 | if (!context) return; | 1044 | if (!context) return; |
| @@ -995,14 +1059,23 @@ function SourceCard({ · 14 unmodified lines |
| 995 | | 1059 | |
| 996 | const TypeIcon = source.type === "mcp" ? Server : Globe; | 1060 | const TypeIcon = source.type === "mcp" ? Server : Globe; |
| 997 | const favicon = getSourceFavicon(source); | 1061 | const favicon = getSourceFavicon(source); |
| 998 | const authBadge = formatSourceAuthBadge(source); | 1062 | const sourceKey = sourceKeyForSource(source) ?? ""; |
| 999 | const auth = readSourceAuth(source); | 1063 | const authBadge = formatSourceAuthBadge(source, sourceAuthProfiles[sourceKey]); |
| | 1064 | const auth = readSourceAuth(source, sourceAuthProfiles[sourceKey]); |
| 1000 | const hasAuthConfigured = auth.type !== "none"; | 1065 | const hasAuthConfigured = auth.type !== "none"; |
| 1001 | const totalCredentials = credentialStats.workspaceCount + credentialStats.actorCount; | 1066 | const totalCredentials = credentialStats.workspaceCount + credentialStats.actorCount; |
| | 1067 | const prettyName = displaySourceName(source.name); |
| | 1068 | const compactEndpoint = compactEndpointLabel(source); |
| | 1069 | const showTypeSummary = source.type === "openapi" && (quality || qualityLoading); |
| 1002 | | 1070 | |
| 1003 | return ( | 1071 | return ( |
| 1004 | <div className="flex items-center gap-3 px-3 py-2.5 rounded-md bg-muted/40 group"> | 1072 | <div |
| 1005 | <div className="h-8 w-8 rounded bg-muted flex items-center justify-center shrink-0 overflow-hidden"> | 1073 | className={cn( |
| | 1074 | "group flex items-start gap-3 rounded-lg border border-border/60 bg-gradient-to-b from-muted/45 to-muted/20 px-3 py-3", |
| | 1075 | selected && "border-primary/35 bg-primary/5", |
| | 1076 | )} |
| | 1077 | > |
| | 1078 | <div className="mt-0.5 h-9 w-9 rounded-md bg-muted/80 flex items-center justify-center shrink-0 overflow-hidden"> |
| 1006 | {favicon ? ( | 1079 | {favicon ? ( |
| 1007 | <img src={favicon} alt="" width={20} height={20} className="w-5 h-5" loading="lazy" /> | 1080 | <img src={favicon} alt="" width={20} height={20} className="w-5 h-5" loading="lazy" /> |
| 1008 | ) : ( | 1081 | ) : ( |
| @@ -1010,20 +1083,20 @@ function SourceCard({ · 1 unmodified line |
| 1010 | )} | 1083 | )} |
| 1011 | </div> | 1084 | </div> |
| 1012 | <div className="flex-1 min-w-0"> | 1085 | <div className="flex-1 min-w-0"> |
| 1013 | <div className="flex items-center gap-2"> | 1086 | <div className="flex items-center gap-2 min-w-0"> |
| 1014 | <span className="text-sm font-mono font-medium truncate"> | 1087 | <span className="text-sm font-medium truncate" title={source.name}> |
| 1015 | {source.name} | 1088 | {prettyName} |
| 1016 | </span> | 1089 | </span> |
| 1017 | <Badge | 1090 | <Badge |
| 1018 | variant="outline" | 1091 | variant="outline" |
| 1019 | className="text-[9px] font-mono uppercase tracking-wider" | 1092 | className="text-[9px] uppercase tracking-wide" |
| 1020 | > | 1093 | > |
| 1021 | {source.type} | 1094 | {source.type} |
| 1022 | </Badge> | 1095 | </Badge> |
| 1023 | {!source.enabled && ( | 1096 | {!source.enabled && ( |
| 1024 | <Badge | 1097 | <Badge |
| 1025 | variant="outline" | 1098 | variant="outline" |
| 1026 | className="text-[9px] font-mono uppercase tracking-wider text-terminal-red border-terminal-red/30" | 1099 | className="text-[9px] uppercase tracking-wide text-terminal-red border-terminal-red/30" |
| 1027 | > | 1100 | > |
| 1028 | disabled | 1101 | disabled |
| 1029 | </Badge> | 1102 | </Badge> |
| @@ -1031,7 +1104,7 @@ function SourceCard({ · 1 unmodified line |
| 1031 | {authBadge && ( | 1104 | {authBadge && ( |
| 1032 | <Badge | 1105 | <Badge |
| 1033 | variant="outline" | 1106 | variant="outline" |
| 1034 | className="text-[9px] font-mono uppercase tracking-wider text-primary border-primary/30" | 1107 | className="text-[9px] uppercase tracking-wide text-primary border-primary/30" |
| 1035 | > | 1108 | > |
| 1036 | {authBadge} | 1109 | {authBadge} |
| 1037 | </Badge> | 1110 | </Badge> |
| @@ -1040,43 +1113,100 @@ function SourceCard({ · 2 unmodified lines |
| 1040 | <Badge | 1113 | <Badge |
| 1041 | variant="outline" | 1114 | variant="outline" |
| 1042 | className={cn( | 1115 | className={cn( |
| 1043 | "text-[9px] font-mono uppercase tracking-wider", | 1116 | "text-[9px] uppercase tracking-wide", |
| 1044 | totalCredentials > 0 | 1117 | totalCredentials > 0 |
| 1045 | ? "text-terminal-green border-terminal-green/30" | 1118 | ? "text-terminal-green border-terminal-green/30" |
| 1046 | : "text-terminal-amber border-terminal-amber/30", | 1119 | : "text-terminal-amber border-terminal-amber/30", |
| 1047 | )} | 1120 | )} |
| 1048 | > | 1121 | > |
| 1049 | creds ws:{credentialStats.workspaceCount} actor:{credentialStats.actorCount} | 1122 | {totalCredentials > 0 ? "connections ready" : "connection needed"} |
| 1050 | </Badge> | | |
| 1051 | )} | | |
| 1052 | {source.type === "openapi" && quality && ( | | |
| 1053 | <Badge | | |
| 1054 | variant="outline" | | |
| 1055 | className={cn("text-[9px] font-mono uppercase tracking-wider", qualityBadgeClass(quality))} | | |
| 1056 | > | | |
| 1057 | quality {formatQualityPercent(quality.overallQuality)} | | |
| 1058 | </Badge> | 1123 | </Badge> |
| 1059 | )} | 1124 | )} |
| 1060 | </div> | 1125 | </div> |
| 1061 | <span className="text-[11px] text-muted-foreground font-mono truncate block"> | 1126 | <span className="text-[11px] text-muted-foreground truncate block mt-0.5" title={sourceEndpointLabel(source)}> |
| 1062 | {sourceEndpointLabel(source)} | 1127 | {compactEndpoint} |
| 1063 | </span> | 1128 | </span> |
| 1064 | {source.type === "openapi" && quality && ( | 1129 | {showTypeSummary && ( |
| 1065 | <span className="text-[10px] text-muted-foreground/90 font-mono truncate block mt-0.5"> | 1130 | <div className="mt-1.5 flex items-center gap-2"> |
| 1066 | args {formatQualityPercent(quality.argsQuality)} | returns {formatQualityPercent(quality.returnsQuality)} | 1131 | {quality ? ( |
| 1067 | {quality.unknownReturnsCount > 0 | 1132 | <Badge |
| 1068 | ? ` | ${quality.unknownReturnsCount} unknown returns` | 1133 | variant="outline" |
| 1069 | : " | fully typed returns"} | 1134 | className={cn("text-[9px] uppercase tracking-wide", qualityToneClass(quality))} |
| 1070 | </span> | 1135 | > |
| | 1136 | {formatQualityPercent(quality.overallQuality)} {qualitySummaryLabel(quality)} |
| | 1137 | </Badge> |
| | 1138 | ) : ( |
| | 1139 | <Badge variant="outline" className="text-[9px] uppercase tracking-wide text-muted-foreground"> |
| | 1140 | analyzing type quality |
| | 1141 | </Badge> |
| | 1142 | )} |
| | 1143 | </div> |
| 1071 | )} | 1144 | )} |
| 1072 | {source.type === "openapi" && !quality && qualityLoading && ( | 1145 | {source.type === "openapi" && ( |
| 1073 | <span className="text-[10px] text-muted-foreground/70 font-mono truncate block mt-0.5"> | 1146 | <Collapsible open={detailsOpen} onOpenChange={setDetailsOpen}> |
| 1074 | Computing OpenAPI type quality... | 1147 | <div className="mt-1.5"> |
| 1075 | </span> | 1148 | <CollapsibleTrigger asChild> |
| | 1149 | <Button variant="ghost" size="sm" className="h-6 px-1.5 text-[10px] text-muted-foreground"> |
| | 1150 | <ChevronRight |
| | 1151 | className={cn("mr-1 h-3 w-3 transition-transform", detailsOpen && "rotate-90")} |
| | 1152 | /> |
| | 1153 | {detailsOpen ? "Hide details" : "View details"} |
| | 1154 | </Button> |
| | 1155 | </CollapsibleTrigger> |
| | 1156 | </div> |
| | 1157 | <CollapsibleContent className="mt-1.5"> |
| | 1158 | <div className="rounded-md border border-border/60 bg-background/70 px-2.5 py-2"> |
| | 1159 | {quality && ( |
| | 1160 | <div className="space-y-1.5 text-[10px]"> |
| | 1161 | <div className="flex items-center justify-between"> |
| | 1162 | <span className="text-muted-foreground">Type quality</span> |
| | 1163 | <span className={cn("font-medium", qualityToneClass(quality))}> |
| | 1164 | {formatQualityPercent(quality.overallQuality)} |
| | 1165 | </span> |
| | 1166 | </div> |
| | 1167 | <div className="flex items-center justify-between text-muted-foreground"> |
| | 1168 | <span>Args quality</span> |
| | 1169 | <span>{formatQualityPercent(quality.argsQuality)}</span> |
| | 1170 | </div> |
| | 1171 | <div className="flex items-center justify-between text-muted-foreground"> |
| | 1172 | <span>Returns quality</span> |
| | 1173 | <span>{formatQualityPercent(quality.returnsQuality)}</span> |
| | 1174 | </div> |
| | 1175 | <div className="flex items-center justify-between text-muted-foreground"> |
| | 1176 | <span>Inferred returns</span> |
| | 1177 | <span>{quality.unknownReturnsCount}</span> |
| | 1178 | </div> |
| | 1179 | </div> |
| | 1180 | )} |
| | 1181 | {!quality && qualityLoading && ( |
| | 1182 | <div className="flex items-center justify-between text-[10px] text-muted-foreground"> |
| | 1183 | <span>Analyzing OpenAPI typing</span> |
| | 1184 | <span className="inline-flex items-center gap-1 text-[10px]"> |
| | 1185 | <span className="h-1.5 w-1.5 rounded-full bg-primary/60 animate-pulse" /> |
| | 1186 | in progress |
| | 1187 | </span> |
| | 1188 | </div> |
| | 1189 | )} |
| | 1190 | {!quality && !qualityLoading && ( |
| | 1191 | <div className="text-[10px] text-muted-foreground">Type quality data unavailable.</div> |
| | 1192 | )} |
| | 1193 | </div> |
| | 1194 | </CollapsibleContent> |
| | 1195 | </Collapsible> |
| 1076 | )} | 1196 | )} |
| 1077 | </div> | 1197 | </div> |
| 1078 | <div className="flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity shrink-0"> | 1198 | <div className="flex items-center gap-1 opacity-100 sm:opacity-0 sm:group-hover:opacity-100 transition-opacity shrink-0"> |
| 1079 | <ConfigureSourceAuthDialog source={source} /> | 1199 | {onFocusSource ? ( |
| | 1200 | <Button |
| | 1201 | variant={selected ? "default" : "outline"} |
| | 1202 | size="sm" |
| | 1203 | className="h-7 text-[11px]" |
| | 1204 | onClick={() => onFocusSource(source.name)} |
| | 1205 | > |
| | 1206 | {selected ? "Viewing" : "View tools"} |
| | 1207 | </Button> |
| | 1208 | ) : null} |
| | 1209 | <ConfigureSourceAuthDialog source={source} inferredProfile={sourceAuthProfiles[sourceKey]} /> |
| 1080 | <Button | 1210 | <Button |
| 1081 | variant="ghost" | 1211 | variant="ghost" |
| 1082 | size="icon" | 1212 | size="icon" |
| @@ -1091,62 +1221,109 @@ function SourceCard({ · 8 unmodified lines |
| 1091 | ); | 1221 | ); |
| 1092 | } | 1222 | } |
| 1093 | | 1223 | |
| 1094 | function formatCredentialSecret(secretJson: Record<string, unknown>): string { | | |
| 1095 | try { | | |
| 1096 | return JSON.stringify(secretJson, null, 2); | | |
| 1097 | } catch { | | |
| 1098 | return "{}"; | | |
| 1099 | } | | |
| 1100 | } | | |
| 1101 | | | |
| 1102 | type SourceOption = { source: ToolSourceRecord; key: string; label: string }; | 1224 | type SourceOption = { source: ToolSourceRecord; key: string; label: string }; |
| 1103 | | 1225 | |
| 1104 | function sourceAuthForKey(sourceOptions: SourceOption[], key: string): { | 1226 | function sourceAuthForKey( |
| | 1227 | sourceOptions: SourceOption[], |
| | 1228 | key: string, |
| | 1229 | inferredProfiles: Record<string, SourceAuthProfile> = {}, |
| | 1230 | ): { |
| 1105 | type: SourceAuthType; | 1231 | type: SourceAuthType; |
| 1106 | mode?: SourceAuthMode; | 1232 | mode?: SourceAuthMode; |
| 1107 | header?: string; | 1233 | header?: string; |
| | 1234 | inferred?: boolean; |
| 1108 | } { | 1235 | } { |
| 1109 | const match = sourceOptions.find((entry) => entry.key === key); | 1236 | const match = sourceOptions.find((entry) => entry.key === key); |
| 1110 | if (!match) { | 1237 | if (!match) { |
| 1111 | return { type: "bearer" }; | 1238 | return { type: "bearer" }; |
| 1112 | } | 1239 | } |
| 1113 | return readSourceAuth(match.source); | 1240 | return readSourceAuth(match.source, inferredProfiles[key]); |
| 1114 | } | 1241 | } |
| 1115 | | 1242 | |
| 1116 | function sourceOptionLabel(source: ToolSourceRecord): string { | 1243 | function sourceOptionLabel(source: ToolSourceRecord): string { |
| 1117 | return `${source.name} (${source.type})`; | 1244 | return `${source.name} (${source.type})`; |
| 1118 | } | 1245 | } |
| 1119 | | 1246 | |
| 1120 | function parseJsonObject(text: string): { value?: Record<string, unknown>; error?: string } { | 1247 | function providerLabel(provider: "local-convex" | "workos-vault"): string { |
| 1121 | try { | 1248 | return provider === "workos-vault" ? "encrypted" : "local"; |
| 1122 | const parsed = JSON.parse(text); | 1249 | } |
| 1123 | if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) { | 1250 | |
| 1124 | return { error: "Credential JSON must be an object" }; | 1251 | function connectionDisplayName( |
| | 1252 | sources: ToolSourceRecord[], |
| | 1253 | connection: { |
| | 1254 | scope: CredentialScope; |
| | 1255 | sourceKeys: Set<string>; |
| | 1256 | actorId?: string; |
| | 1257 | }, |
| | 1258 | ): string { |
| | 1259 | const sourceNames = [...connection.sourceKeys] |
| | 1260 | .map((sourceKey) => sourceForCredentialKey(sources, sourceKey)) |
| | 1261 | .filter((source): source is ToolSourceRecord => Boolean(source)) |
| | 1262 | .map((source) => displaySourceName(source.name)); |
| | 1263 | |
| | 1264 | const primary = sourceNames[0] ?? "API"; |
| | 1265 | const extraCount = Math.max(sourceNames.length - 1, 0); |
| | 1266 | const base = extraCount > 0 ? `${primary} +${extraCount}` : primary; |
| | 1267 | |
| | 1268 | if (connection.scope === "actor") { |
| | 1269 | if (connection.actorId) { |
| | 1270 | return `${base} personal (${connection.actorId})`; |
| 1125 | } | 1271 | } |
| 1126 | return { value: parsed as Record<string, unknown> }; | 1272 | return `${base} personal`; |
| 1127 | } catch (error) { | | |
| 1128 | return { error: error instanceof Error ? error.message : "Invalid credential JSON" }; | | |
| 1129 | } | 1273 | } |
| | 1274 | |
| | 1275 | return `${base} workspace`; |
| 1130 | } | 1276 | } |
| 1131 | | 1277 | |
| 1132 | function asString(value: unknown): string { | 1278 | function parseHeaderOverrides(text: string): { value?: Record<string, string>; error?: string } { |
| 1133 | return typeof value === "string" ? value : ""; | 1279 | const lines = text |
| | 1280 | .split("\n") |
| | 1281 | .map((line) => line.trim()) |
| | 1282 | .filter((line) => line.length > 0); |
| | 1283 | if (lines.length === 0) { |
| | 1284 | return { value: {} }; |
| | 1285 | } |
| | 1286 | |
| | 1287 | const headers: Record<string, string> = {}; |
| | 1288 | for (const line of lines) { |
| | 1289 | const separator = line.indexOf(":"); |
| | 1290 | if (separator <= 0) { |
| | 1291 | return { error: `Invalid header line: ${line}` }; |
| | 1292 | } |
| | 1293 | const name = line.slice(0, separator).trim(); |
| | 1294 | const value = line.slice(separator + 1).trim(); |
| | 1295 | if (!name || !value) { |
| | 1296 | return { error: `Invalid header line: ${line}` }; |
| | 1297 | } |
| | 1298 | headers[name] = value; |
| | 1299 | } |
| | 1300 | |
| | 1301 | return { value: headers }; |
| 1134 | } | 1302 | } |
| 1135 | | 1303 | |
| 1136 | function providerLabel(provider: "managed" | "workos-vault"): string { | 1304 | function formatHeaderOverrides(overrides: Record<string, unknown> | undefined): string { |
| 1137 | return provider === "workos-vault" ? "encrypted" : "managed"; | 1305 | const headers = overrides && typeof overrides.headers === "object" ? (overrides.headers as Record<string, unknown>) : {}; |
| | 1306 | return Object.entries(headers) |
| | 1307 | .map(([key, value]) => `${key}: ${String(value)}`) |
| | 1308 | .join("\n"); |
| 1138 | } | 1309 | } |
| 1139 | | 1310 | |
| 1140 | function CredentialsPanel({ | 1311 | function CredentialsPanel({ |
| 1141 | sources, | 1312 | sources, |
| 1142 | credentials, | 1313 | credentials, |
| | 1314 | sourceAuthProfiles, |
| 1143 | loading, | 1315 | loading, |
| | 1316 | focusSourceKey, |
| | 1317 | onFocusHandled, |
| 1144 | }: { | 1318 | }: { |
| 1145 | sources: ToolSourceRecord[]; | 1319 | sources: ToolSourceRecord[]; |
| 1146 | credentials: CredentialRecord[]; | 1320 | credentials: CredentialRecord[]; |
| | 1321 | sourceAuthProfiles: Record<string, SourceAuthProfile>; |
| 1147 | loading: boolean; | 1322 | loading: boolean; |
| | 1323 | focusSourceKey?: string | null; |
| | 1324 | onFocusHandled?: () => void; |
| 1148 | }) { | 1325 | }) { |
| 1149 | const { context } = useSession(); | 1326 | const { context, clientConfig } = useSession(); |
| 1150 | const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential); | 1327 | const upsertCredential = useAction(convexApi.credentialsNode.upsertCredential); |
| 1151 | const [open, setOpen] = useState(false); | 1328 | const [open, setOpen] = useState(false); |
| 1152 | const [saving, setSaving] = useState(false); | 1329 | const [saving, setSaving] = useState(false); |
| @@ -1154,13 +1331,17 @@ function CredentialsPanel({ · 1 unmodified line |
| 1154 | const [sourceKey, setSourceKey] = useState(""); | 1331 | const [sourceKey, setSourceKey] = useState(""); |
| 1155 | const [scope, setScope] = useState<CredentialScope>("workspace"); | 1332 | const [scope, setScope] = useState<CredentialScope>("workspace"); |
| 1156 | const [actorId, setActorId] = useState(""); | 1333 | const [actorId, setActorId] = useState(""); |
| 1157 | const [provider, setProvider] = useState<"managed" | "workos-vault">("managed"); | 1334 | const [connectionMode, setConnectionMode] = useState<"new" | "existing">("new"); |
| 1158 | const [managedToken, setManagedToken] = useState(""); | 1335 | const [existingConnectionId, setExistingConnectionId] = useState(""); |
| | 1336 | const [tokenValue, setTokenValue] = useState(""); |
| 1159 | const [apiKeyValue, setApiKeyValue] = useState(""); | 1337 | const [apiKeyValue, setApiKeyValue] = useState(""); |
| 1160 | const [basicUsername, setBasicUsername] = useState(""); | 1338 | const [basicUsername, setBasicUsername] = useState(""); |
| 1161 | const [basicPassword, setBasicPassword] = useState(""); | 1339 | const [basicPassword, setBasicPassword] = useState(""); |
| 1162 | const [advancedMode, setAdvancedMode] = useState(false); | 1340 | const [customHeadersText, setCustomHeadersText] = useState(""); |
| 1163 | const [secretJsonText, setSecretJsonText] = useState("{}"); | 1341 | |
| | 1342 | const storageCopy = clientConfig?.authProviderMode === "workos" |
| | 1343 | ? "Stored encrypted" |
| | 1344 | : "Stored locally on this machine"; |
| 1164 | | 1345 | |
| 1165 | const sourceOptions = sources | 1346 | const sourceOptions = sources |
| 1166 | .map((source) => { | 1347 | .map((source) => { |
| @@ -1173,50 +1354,99 @@ function CredentialsPanel({ · 6 unmodified lines |
| 1173 | }) | 1354 | }) |
| 1174 | .filter((entry): entry is SourceOption => entry.key !== null); | 1355 | .filter((entry): entry is SourceOption => entry.key !== null); |
| 1175 | | 1356 | |
| 1176 | const selectedAuth = sourceAuthForKey(sourceOptions, sourceKey); | 1357 | const connectionOptions = useMemo(() => { |
| | 1358 | const grouped = new Map<string, { |
| | 1359 | id: string; |
| | 1360 | scope: CredentialScope; |
| | 1361 | actorId?: string; |
| | 1362 | provider: "local-convex" | "workos-vault"; |
| | 1363 | sourceKeys: Set<string>; |
| | 1364 | updatedAt: number; |
| | 1365 | }>(); |
| | 1366 | |
| | 1367 | for (const credential of credentials) { |
| | 1368 | const existing = grouped.get(credential.id); |
| | 1369 | if (existing) { |
| | 1370 | existing.sourceKeys.add(credential.sourceKey); |
| | 1371 | existing.updatedAt = Math.max(existing.updatedAt, credential.updatedAt); |
| | 1372 | } else { |
| | 1373 | grouped.set(credential.id, { |
| | 1374 | id: credential.id, |
| | 1375 | scope: credential.scope, |
| | 1376 | actorId: credential.actorId, |
| | 1377 | provider: credential.provider, |
| | 1378 | sourceKeys: new Set([credential.sourceKey]), |
| | 1379 | updatedAt: credential.updatedAt, |
| | 1380 | }); |
| | 1381 | } |
| | 1382 | } |
| | 1383 | |
| | 1384 | return [...grouped.values()].sort((a, b) => b.updatedAt - a.updatedAt); |
| | 1385 | }, [credentials]); |
| | 1386 | |
| | 1387 | const representativeCredentialByConnection = useMemo(() => { |
| | 1388 | const map = new Map<string, CredentialRecord>(); |
| | 1389 | for (const credential of credentials) { |
| | 1390 | if (!map.has(credential.id)) { |
| | 1391 | map.set(credential.id, credential); |
| | 1392 | } |
| | 1393 | } |
| | 1394 | return map; |
| | 1395 | }, [credentials]); |
| | 1396 | |
| | 1397 | const selectedAuth = sourceAuthForKey(sourceOptions, sourceKey, sourceAuthProfiles); |
| | 1398 | const compatibleConnectionOptions = connectionOptions.filter((connection) => { |
| | 1399 | if (connection.scope !== scope) { |
| | 1400 | return false; |
| | 1401 | } |
| | 1402 | if (scope === "actor") { |
| | 1403 | return connection.actorId === actorId.trim(); |
| | 1404 | } |
| | 1405 | return true; |
| | 1406 | }); |
| 1177 | const selectedAuthBadge = selectedAuth.type === "none" | 1407 | const selectedAuthBadge = selectedAuth.type === "none" |
| 1178 | ? "none" | 1408 | ? "none" |
| 1179 | : `${selectedAuth.type}:${selectedAuth.mode ?? "workspace"}`; | 1409 | : selectedAuth.type === "mixed" |
| | 1410 | ? "mixed" |
| | 1411 | : `${selectedAuth.type}:${selectedAuth.mode ?? "workspace"}`; |
| 1180 | | 1412 | |
| 1181 | const buildDraftSecretFromInputs = (): Record<string, unknown> => { | 1413 | useEffect(() => { |
| 1182 | if (selectedAuth.type === "apiKey") { | 1414 | if (!existingConnectionId) { |
| 1183 | return { value: apiKeyValue.trim() }; | 1415 | return; |
| 1184 | } | 1416 | } |
| 1185 | if (selectedAuth.type === "basic") { | 1417 | if (!compatibleConnectionOptions.some((connection) => connection.id === existingConnectionId)) { |
| 1186 | return { | 1418 | setExistingConnectionId(""); |
| 1187 | username: basicUsername, | | |
| 1188 | password: basicPassword, | | |
| 1189 | }; | | |
| 1190 | } | 1419 | } |
| 1191 | return { token: managedToken.trim() }; | 1420 | }, [compatibleConnectionOptions, existingConnectionId]); |
| 1192 | }; | | |
| 1193 | | | |
| 1194 | const setFormFromCredential = (credential: CredentialRecord) => { | | |
| 1195 | const secret = credential.secretJson; | | |
| 1196 | setManagedToken(asString(secret.token) || asString(secret.value)); | | |
| 1197 | setApiKeyValue(asString(secret.value) || asString(secret.token)); | | |
| 1198 | setBasicUsername(asString(secret.username)); | | |
| 1199 | setBasicPassword(asString(secret.password)); | | |
| 1200 | setSecretJsonText(formatCredentialSecret(secret)); | | |
| 1201 | setAdvancedMode(false); | | |
| 1202 | }; | | |
| 1203 | | 1421 | |
| 1204 | const resetForm = () => { | 1422 | const resetForm = () => { |
| 1205 | const defaultSourceKey = sourceOptions[0]?.key ?? ""; | 1423 | const defaultSourceKey = sourceOptions[0]?.key ?? ""; |
| 1206 | setSourceKey(defaultSourceKey); | 1424 | setSourceKey(defaultSourceKey); |
| 1207 | const defaultAuth = sourceAuthForKey(sourceOptions, defaultSourceKey); | 1425 | const defaultAuth = sourceAuthForKey(sourceOptions, defaultSourceKey, sourceAuthProfiles); |
| 1208 | setScope(defaultAuth.mode ?? "workspace"); | 1426 | setScope(defaultAuth.mode ?? "workspace"); |
| 1209 | setActorId(context?.actorId ?? ""); | 1427 | setActorId(context?.actorId ?? ""); |
| 1210 | setProvider("managed"); | 1428 | setConnectionMode("new"); |
| 1211 | setManagedToken(""); | 1429 | setExistingConnectionId(""); |
| | 1430 | setTokenValue(""); |
| 1212 | setApiKeyValue(""); | 1431 | setApiKeyValue(""); |
| 1213 | setBasicUsername(""); | 1432 | setBasicUsername(""); |
| 1214 | setBasicPassword(""); | 1433 | setBasicPassword(""); |
| 1215 | setAdvancedMode(false); | 1434 | setCustomHeadersText(""); |
| 1216 | setSecretJsonText("{}"); | | |
| 1217 | setEditing(null); | 1435 | setEditing(null); |
| 1218 | }; | 1436 | }; |
| 1219 | | 1437 | |
| | 1438 | useEffect(() => { |
| | 1439 | if (!focusSourceKey) { |
| | 1440 | return; |
| | 1441 | } |
| | 1442 | resetForm(); |
| | 1443 | setSourceKey(focusSourceKey); |
| | 1444 | const auth = sourceAuthForKey(sourceOptions, focusSourceKey, sourceAuthProfiles); |
| | 1445 | setScope(auth.mode ?? "workspace"); |
| | 1446 | setOpen(true); |
| | 1447 | onFocusHandled?.(); |
| | 1448 | }, [focusSourceKey, onFocusHandled, sourceAuthProfiles, sourceOptions]); |
| | 1449 | |
| 1220 | const openForCreate = () => { | 1450 | const openForCreate = () => { |
| 1221 | resetForm(); | 1451 | resetForm(); |
| 1222 | setOpen(true); | 1452 | setOpen(true); |
| @@ -1227,30 +1457,24 @@ function CredentialsPanel({ · 4 unmodified lines |
| 1227 | setSourceKey(credential.sourceKey); | 1457 | setSourceKey(credential.sourceKey); |
| 1228 | setScope(credential.scope); | 1458 | setScope(credential.scope); |
| 1229 | setActorId(credential.actorId ?? context?.actorId ?? ""); | 1459 | setActorId(credential.actorId ?? context?.actorId ?? ""); |
| 1230 | setProvider(credential.provider === "workos-vault" ? "workos-vault" : "managed"); | 1460 | setConnectionMode("new"); |
| 1231 | setFormFromCredential(credential); | 1461 | setExistingConnectionId(credential.id); |
| | 1462 | setTokenValue(""); |
| | 1463 | setApiKeyValue(""); |
| | 1464 | setBasicUsername(""); |
| | 1465 | setBasicPassword(""); |
| | 1466 | setCustomHeadersText(formatHeaderOverrides(credential.overridesJson)); |
| 1232 | setOpen(true); | 1467 | setOpen(true); |
| 1233 | }; | 1468 | }; |
| 1234 | | 1469 | |
| 1235 | const handleSourceKeyChange = (nextSourceKey: string) => { | 1470 | const handleSourceKeyChange = (nextSourceKey: string) => { |
| 1236 | setSourceKey(nextSourceKey); | 1471 | setSourceKey(nextSourceKey); |
| 1237 | const auth = sourceAuthForKey(sourceOptions, nextSourceKey); | 1472 | const auth = sourceAuthForKey(sourceOptions, nextSourceKey, sourceAuthProfiles); |
| 1238 | if (!editing) { | 1473 | if (!editing) { |
| 1239 | setScope(auth.mode ?? "workspace"); | 1474 | setScope(auth.mode ?? "workspace"); |
| 1240 | } | 1475 | } |
| 1241 | }; | 1476 | }; |
| 1242 | | 1477 | |
| 1243 | const handleProviderChange = (value: "managed" | "workos-vault") => { | | |
| 1244 | setProvider(value); | | |
| 1245 | }; | | |
| 1246 | | | |
| 1247 | const handleAdvancedModeChange = (next: boolean) => { | | |
| 1248 | setAdvancedMode(next); | | |
| 1249 | if (next) { | | |
| 1250 | setSecretJsonText(formatCredentialSecret(buildDraftSecretFromInputs())); | | |
| 1251 | } | | |
| 1252 | }; | | |
| 1253 | | | |
| 1254 | const handleSave = async () => { | 1478 | const handleSave = async () => { |
| 1255 | if (!context) return; | 1479 | if (!context) return; |
| 1256 | if (!sourceKey.trim()) { | 1480 | if (!sourceKey.trim()) { |
| @@ -1262,61 +1486,61 @@ function CredentialsPanel({ · 5 unmodified lines |
| 1262 | return; | 1486 | return; |
| 1263 | } | 1487 | } |
| 1264 | | 1488 | |
| 1265 | let secretJson: Record<string, unknown> = {}; | 1489 | const parsedHeaders = parseHeaderOverrides(customHeadersText); |
| 1266 | const keepExistingEncryptedSecret = provider === "workos-vault" && Boolean(editing); | 1490 | if (!parsedHeaders.value) { |
| | 1491 | toast.error(parsedHeaders.error ?? "Invalid header overrides"); |
| | 1492 | return; |
| | 1493 | } |
| 1267 | | 1494 | |
| 1268 | if (advancedMode) { | 1495 | const linkExisting = !editing && connectionMode === "existing"; |
| 1269 | const parsed = parseJsonObject(secretJsonText); | 1496 | if (linkExisting && !existingConnectionId) { |
| 1270 | if (!parsed.value) { | 1497 | toast.error("Select an existing connection to link"); |
| 1271 | toast.error(parsed.error ?? "Invalid credential JSON"); | 1498 | return; |
| 1272 | return; | 1499 | } |
| 1273 | } | 1500 | if (linkExisting && !compatibleConnectionOptions.some((connection) => connection.id === existingConnectionId)) { |
| 1274 | secretJson = parsed.value; | 1501 | toast.error("Selected connection does not match this scope"); |
| 1275 | } else { | 1502 | return; |
| 1276 | if (selectedAuth.type === "none") { | 1503 | } |
| 1277 | toast.error("Configure source auth before saving credentials"); | 1504 | |
| 1278 | return; | 1505 | if (selectedAuth.type === "none") { |
| 1279 | } | 1506 | toast.error("This source does not require auth"); |
| | 1507 | return; |
| | 1508 | } |
| | 1509 | |
| | 1510 | if (selectedAuth.type === "mixed" && !linkExisting && !editing) { |
| | 1511 | toast.error("Mixed-auth sources must link to an existing connection"); |
| | 1512 | return; |
| | 1513 | } |
| | 1514 | |
| | 1515 | const secretJson: Record<string, unknown> = {}; |
| | 1516 | if (!linkExisting) { |
| 1280 | if (selectedAuth.type === "basic") { | 1517 | if (selectedAuth.type === "basic") { |
| 1281 | const hasUsername = basicUsername.trim().length > 0; | 1518 | const hasUsername = basicUsername.trim().length > 0; |
| 1282 | const hasPassword = basicPassword.trim().length > 0; | 1519 | const hasPassword = basicPassword.trim().length > 0; |
| 1283 | if (!hasUsername && !hasPassword && keepExistingEncryptedSecret) { | 1520 | if (hasUsername || hasPassword) { |
| 1284 | secretJson = {}; | 1521 | if (!hasUsername || !hasPassword) { |
| 1285 | } else if (!hasUsername || !hasPassword) { | 1522 | toast.error("Username and password are required for basic auth"); |
| 1286 | toast.error("Username and password are required for basic auth"); | | |
| 1287 | return; | | |
| 1288 | } else { | | |
| 1289 | secretJson = { | | |
| 1290 | username: basicUsername, | | |
| 1291 | password: basicPassword, | | |
| 1292 | }; | | |
| 1293 | } | | |
| 1294 | } else if (selectedAuth.type === "apiKey") { | | |
| 1295 | if (!apiKeyValue.trim()) { | | |
| 1296 | if (keepExistingEncryptedSecret) { | | |
| 1297 | secretJson = {}; | | |
| 1298 | } else { | | |
| 1299 | toast.error("API key value is required"); | | |
| 1300 | return; | 1523 | return; |
| 1301 | } | 1524 | } |
| 1302 | } else { | 1525 | secretJson.username = basicUsername; |
| 1303 | secretJson = { value: apiKeyValue.trim() }; | 1526 | secretJson.password = basicPassword; |
| 1304 | } | 1527 | } |
| 1305 | } else { | 1528 | } else if (selectedAuth.type === "apiKey") { |
| 1306 | if (!managedToken.trim()) { | 1529 | if (apiKeyValue.trim()) { |
| 1307 | if (keepExistingEncryptedSecret) { | 1530 | secretJson.value = apiKeyValue.trim(); |
| 1308 | secretJson = {}; | 1531 | } |
| 1309 | } else { | 1532 | } else if (selectedAuth.type === "bearer") { |
| 1310 | toast.error("Token is required"); | 1533 | if (tokenValue.trim()) { |
| 1311 | return; | 1534 | secretJson.token = tokenValue.trim(); |
| 1312 | } | | |
| 1313 | } else { | | |
| 1314 | secretJson = { token: managedToken.trim() }; | | |
| 1315 | } | 1535 | } |
| 1316 | } | 1536 | } |
| 1317 | } | 1537 | } |
| 1318 | | 1538 | |
| 1319 | if (provider === "workos-vault" && !editing && Object.keys(secretJson).length === 0) { | 1539 | if (Object.keys(parsedHeaders.value).length > 0) { |
| | 1540 | secretJson.__headers = parsedHeaders.value; |
| | 1541 | } |
| | 1542 | |
| | 1543 | if (Object.keys(secretJson).length === 0 && !editing && !linkExisting) { |
| 1320 | if (selectedAuth.type === "basic") { | 1544 | if (selectedAuth.type === "basic") { |
| 1321 | toast.error("Username and password are required"); | 1545 | toast.error("Username and password are required"); |
| 1322 | } else if (selectedAuth.type === "apiKey") { | 1546 | } else if (selectedAuth.type === "apiKey") { |
| @@ -1330,20 +1554,20 @@ function CredentialsPanel({ · 7 unmodified lines |
| 1330 | setSaving(true); | 1554 | setSaving(true); |
| 1331 | try { | 1555 | try { |
| 1332 | await upsertCredential({ | 1556 | await upsertCredential({ |
| | 1557 | ...(editing ? { id: editing.id } : linkExisting ? { id: existingConnectionId } : {}), |
| 1333 | workspaceId: context.workspaceId, | 1558 | workspaceId: context.workspaceId, |
| 1334 | sessionId: context.sessionId, | 1559 | sessionId: context.sessionId, |
| 1335 | sourceKey: sourceKey.trim(), | 1560 | sourceKey: sourceKey.trim(), |
| 1336 | scope, | 1561 | scope, |
| 1337 | ...(scope === "actor" ? { actorId: actorId.trim() } : {}), | 1562 | ...(scope === "actor" ? { actorId: actorId.trim() } : {}), |
| 1338 | provider, | | |
| 1339 | secretJson, | 1563 | secretJson, |
| 1340 | }); | 1564 | }); |
| 1341 | | 1565 | |
| 1342 | toast.success(editing ? "Credential updated" : "Credential saved"); | 1566 | toast.success(editing ? "Connection updated" : linkExisting ? "Connection linked" : "Connection saved"); |
| 1343 | setOpen(false); | 1567 | setOpen(false); |
| 1344 | resetForm(); | 1568 | resetForm(); |
| 1345 | } catch (err) { | 1569 | } catch (err) { |
| 1346 | toast.error(err instanceof Error ? err.message : "Failed to save credential"); | 1570 | toast.error(err instanceof Error ? err.message : "Failed to save connection"); |
| 1347 | } finally { | 1571 | } finally { |
| 1348 | setSaving(false); | 1572 | setSaving(false); |
| 1349 | } | 1573 | } |
| @@ -1355,11 +1579,11 @@ function CredentialsPanel({ · 5 unmodified lines |
| 1355 | <div className="flex items-center justify-between"> | 1579 | <div className="flex items-center justify-between"> |
| 1356 | <CardTitle className="text-sm font-medium flex items-center gap-2"> | 1580 | <CardTitle className="text-sm font-medium flex items-center gap-2"> |
| 1357 | <KeyRound className="h-4 w-4 text-muted-foreground" /> | 1581 | <KeyRound className="h-4 w-4 text-muted-foreground" /> |
| 1358 | Credentials | 1582 | Connections |
| 1359 | </CardTitle> | 1583 | </CardTitle> |
| 1360 | <Button size="sm" className="h-8 text-xs" onClick={openForCreate}> | 1584 | <Button size="sm" className="h-8 text-xs" onClick={openForCreate}> |
| 1361 | <Plus className="h-3.5 w-3.5 mr-1.5" /> | 1585 | <Plus className="h-3.5 w-3.5 mr-1.5" /> |
| 1362 | Add Credential | 1586 | Add Connection |
| 1363 | </Button> | 1587 | </Button> |
| 1364 | </div> | 1588 | </div> |
| 1365 | </CardHeader> | 1589 | </CardHeader> |
| @@ -1370,82 +1594,71 @@ function CredentialsPanel({ · 4 unmodified lines |
| 1370 | <Skeleton key={i} className="h-14" /> | 1594 | <Skeleton key={i} className="h-14" /> |
| 1371 | ))} | 1595 | ))} |
| 1372 | </div> | 1596 | </div> |
| 1373 | ) : credentials.length === 0 ? ( | 1597 | ) : connectionOptions.length === 0 ? ( |
| 1374 | <div className="flex flex-col items-center justify-center py-10 gap-2"> | 1598 | <div className="flex flex-col items-center justify-center py-10 gap-2"> |
| 1375 | <div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center"> | 1599 | <div className="h-10 w-10 rounded-full bg-muted flex items-center justify-center"> |
| 1376 | <KeyRound className="h-5 w-5 text-muted-foreground/50" /> | 1600 | <KeyRound className="h-5 w-5 text-muted-foreground/50" /> |
| 1377 | </div> | 1601 | </div> |
| 1378 | <p className="text-sm text-muted-foreground">No credentials configured</p> | 1602 | <p className="text-sm text-muted-foreground">No connections configured</p> |
| 1379 | <p className="text-[11px] text-muted-foreground/70 text-center max-w-md"> | 1603 | <p className="text-[11px] text-muted-foreground/70 text-center max-w-md"> |
| 1380 | Configure source auth on an OpenAPI or GraphQL source, then add workspace or actor credentials. | 1604 | Add a source, then create or link a reusable connection. |
| 1381 | </p> | 1605 | </p> |
| 1382 | </div> | 1606 | </div> |
| 1383 | ) : ( | 1607 | ) : ( |
| 1384 | <div className="space-y-2"> | 1608 | <div className="space-y-2"> |
| 1385 | {credentials.map((credential) => ( | 1609 | {connectionOptions.map((connection) => { |
| 1386 | <div | 1610 | const representative = representativeCredentialByConnection.get(connection.id); |
| 1387 | key={`${credential.sourceKey}:${credential.scope}:${credential.actorId ?? "workspace"}`} | 1611 | if (!representative) return null; |
| 1388 | className="flex items-center gap-3 px-3 py-2.5 rounded-md bg-muted/40" | 1612 | const firstSource = sourceForCredentialKey(sources, representative.sourceKey); |
| 1389 | > | 1613 | const favicon = firstSource ? getSourceFavicon(firstSource) : null; |
| 1390 | {(() => { | 1614 | |
| 1391 | const source = sourceForCredentialKey(sources, credential.sourceKey); | 1615 | return ( |
| 1392 | const favicon = source ? getSourceFavicon(source) : null; | 1616 | <div |
| 1393 | return ( | 1617 | key={connection.id} |
| 1394 | <div className="h-8 w-8 rounded bg-muted flex items-center justify-center shrink-0 overflow-hidden"> | 1618 | className="flex items-center gap-3 px-3 py-2.5 rounded-md bg-muted/40" |
| 1395 | {favicon ? ( | 1619 | > |
| 1396 | <img src={favicon} alt="" width={20} height={20} className="w-5 h-5" loading="lazy" /> | 1620 | <div className="h-8 w-8 rounded bg-muted flex items-center justify-center shrink-0 overflow-hidden"> |
| 1397 | ) : ( | 1621 | {favicon ? ( |
| 1398 | <KeyRound className="h-4 w-4 text-muted-foreground" /> | 1622 | <img src={favicon} alt="" width={20} height={20} className="w-5 h-5" loading="lazy" /> |
| | 1623 | ) : ( |
| | 1624 | <KeyRound className="h-4 w-4 text-muted-foreground" /> |
| | 1625 | )} |
| | 1626 | </div> |
| | 1627 | <div className="flex-1 min-w-0"> |
| | 1628 | <div className="flex items-center gap-1.5 mb-1 flex-wrap"> |
| | 1629 | <span className="text-sm font-medium">{connectionDisplayName(sources, connection)}</span> |
| | 1630 | <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> |
| | 1631 | {connection.scope} |
| | 1632 | </Badge> |
| | 1633 | <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> |
| | 1634 | {providerLabel(connection.provider)} |
| | 1635 | </Badge> |
| | 1636 | {connection.scope === "actor" && connection.actorId && ( |
| | 1637 | <span className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded"> |
| | 1638 | {connection.actorId} |
| | 1639 | </span> |
| 1399 | )} | 1640 | )} |
| 1400 | </div> | 1641 | </div> |
| 1401 | ); | 1642 | <p className="text-[11px] text-muted-foreground"> |
| 1402 | })()} | 1643 | Linked to {connection.sourceKeys.size} source{connection.sourceKeys.size === 1 ? "" : "s"} • {storageCopy} |
| 1403 | <div className="flex-1 min-w-0"> | 1644 | </p> |
| 1404 | {(() => { | 1645 | <p className="text-[11px] text-muted-foreground mt-0.5"> |
| 1405 | const source = sourceForCredentialKey(sources, credential.sourceKey); | 1646 | ID: <span className="font-mono">{connection.id}</span> • |
| 1406 | if (!source) { | 1647 | {" "} |
| 1407 | return ( | 1648 | Updated {new Date(connection.updatedAt).toLocaleString()} |
| 1408 | <p className="text-[11px] text-muted-foreground/80 font-mono mb-1"> | 1649 | </p> |
| 1409 | {credential.sourceKey} | | |
| 1410 | </p> | | |
| 1411 | ); | | |
| 1412 | } | | |
| 1413 | return ( | | |
| 1414 | <div className="flex items-center gap-1.5 mb-1"> | | |
| 1415 | <span className="text-sm font-mono font-medium">{source.name}</span> | | |
| 1416 | <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> | | |
| 1417 | {source.type} | | |
| 1418 | </Badge> | | |
| 1419 | </div> | | |
| 1420 | ); | | |
| 1421 | })()} | | |
| 1422 | <div className="flex items-center gap-2 flex-wrap"> | | |
| 1423 | <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> | | |
| 1424 | {credential.scope} | | |
| 1425 | </Badge> | | |
| 1426 | <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> | | |
| 1427 | {providerLabel(credential.provider === "workos-vault" ? "workos-vault" : "managed")} | | |
| 1428 | </Badge> | | |
| 1429 | {credential.scope === "actor" && credential.actorId && ( | | |
| 1430 | <span className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded"> | | |
| 1431 | {credential.actorId} | | |
| 1432 | </span> | | |
| 1433 | )} | | |
| 1434 | </div> | 1650 | </div> |
| 1435 | <p className="text-[11px] text-muted-foreground mt-0.5"> | 1651 | <Button |
| 1436 | Updated {new Date(credential.updatedAt).toLocaleString()} | 1652 | variant="outline" |
| 1437 | </p> | 1653 | size="sm" |
| | 1654 | className="h-7 text-[11px]" |
| | 1655 | onClick={() => openForEdit(representative)} |
| | 1656 | > |
| | 1657 | Edit |
| | 1658 | </Button> |
| 1438 | </div> | 1659 | </div> |
| 1439 | <Button | 1660 | ); |
| 1440 | variant="outline" | 1661 | })} |
| 1441 | size="sm" | | |
| 1442 | className="h-7 text-[11px]" | | |
| 1443 | onClick={() => openForEdit(credential)} | | |
| 1444 | > | | |
| 1445 | Edit | | |
| 1446 | </Button> | | |
| 1447 | </div> | | |
| 1448 | ))} | | |
| 1449 | </div> | 1662 | </div> |
| 1450 | )} | 1663 | )} |
| 1451 | </CardContent> | 1664 | </CardContent> |
| @@ -1454,7 +1667,7 @@ function CredentialsPanel({ · 2 unmodified lines |
| 1454 | <DialogContent className="bg-card border-border sm:max-w-lg"> | 1667 | <DialogContent className="bg-card border-border sm:max-w-lg"> |
| 1455 | <DialogHeader> | 1668 | <DialogHeader> |
| 1456 | <DialogTitle className="text-sm font-medium"> | 1669 | <DialogTitle className="text-sm font-medium"> |
| 1457 | {editing ? "Edit Credential" : "Add Credential"} | 1670 | {editing ? "Edit Connection" : "Add Connection"} |
| 1458 | </DialogTitle> | 1671 | </DialogTitle> |
| 1459 | </DialogHeader> | 1672 | </DialogHeader> |
| 1460 | <div className="space-y-3"> | 1673 | <div className="space-y-3"> |
| @@ -1491,6 +1704,11 @@ function CredentialsPanel({ · 30 unmodified lines |
| 1491 | <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> | 1704 | <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> |
| 1492 | {selectedAuthBadge} | 1705 | {selectedAuthBadge} |
| 1493 | </Badge> | 1706 | </Badge> |
| | 1707 | {selectedAuth.inferred && ( |
| | 1708 | <Badge variant="outline" className="text-[9px] font-mono uppercase tracking-wider"> |
| | 1709 | inferred |
| | 1710 | </Badge> |
| | 1711 | )} |
| 1494 | {selectedAuth.type === "apiKey" && selectedAuth.header && ( | 1712 | {selectedAuth.type === "apiKey" && selectedAuth.header && ( |
| 1495 | <span className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded"> | 1713 | <span className="text-[10px] font-mono text-muted-foreground bg-muted px-1.5 py-0.5 rounded"> |
| 1496 | header: {selectedAuth.header} | 1714 | header: {selectedAuth.header} |
| @@ -1511,18 +1729,9 @@ function CredentialsPanel({ · 14 unmodified lines |
| 1511 | </SelectContent> | 1729 | </SelectContent> |
| 1512 | </Select> | 1730 | </Select> |
| 1513 | </div> | 1731 | </div> |
| 1514 | | | |
| 1515 | <div className="space-y-1.5"> | 1732 | <div className="space-y-1.5"> |
| 1516 | <Label className="text-xs text-muted-foreground">Provider</Label> | 1733 | <Label className="text-xs text-muted-foreground">Storage</Label> |
| 1517 | <Select value={provider} onValueChange={(value) => handleProviderChange(value as "managed" | "workos-vault") }> | 1734 | <Input value={storageCopy} readOnly className="h-8 text-xs bg-background" /> |
| 1518 | <SelectTrigger className="h-8 text-xs bg-background"> | | |
| 1519 | <SelectValue /> | | |
| 1520 | </SelectTrigger> | | |
| 1521 | <SelectContent> | | |
| 1522 | <SelectItem value="managed" className="text-xs">Managed storage</SelectItem> | | |
| 1523 | <SelectItem value="workos-vault" className="text-xs">Encrypted storage</SelectItem> | | |
| 1524 | </SelectContent> | | |
| 1525 | </Select> | | |
| 1526 | </div> | 1735 | </div> |
| 1527 | </div> | 1736 | </div> |
| 1528 | | 1737 | |
| @@ -1538,79 +1747,114 @@ function CredentialsPanel({ · 9 unmodified lines |
| 1538 | </div> | 1747 | </div> |
| 1539 | )} | 1748 | )} |
| 1540 | | 1749 | |
| 1541 | {provider === "workos-vault" && editing && ( | 1750 | {!editing && connectionOptions.length > 0 && ( |
| 1542 | <p className="text-[11px] text-muted-foreground"> | | |
| 1543 | Stored secret is hidden. Enter a new value below to rotate, or leave fields blank to keep existing. | | |
| 1544 | </p> | | |
| 1545 | )} | | |
| 1546 | | | |
| 1547 | {selectedAuth.type === "none" ? ( | | |
| 1548 | <p className="text-[11px] text-terminal-amber"> | | |
| 1549 | This source has auth set to <code>none</code>. Configure source auth first. | | |
| 1550 | </p> | | |
| 1551 | ) : selectedAuth.type === "apiKey" ? ( | | |
| 1552 | <div className="space-y-1.5"> | 1751 | <div className="space-y-1.5"> |
| 1553 | <Label className="text-xs text-muted-foreground">API Key Value</Label> | 1752 | <Label className="text-xs text-muted-foreground">Connection Mode</Label> |
| 1554 | <Input | 1753 | <Select value={connectionMode} onValueChange={(value) => setConnectionMode(value as "new" | "existing") }> |
| 1555 | value={apiKeyValue} | 1754 | <SelectTrigger className="h-8 text-xs bg-background"> |
| 1556 | onChange={(e) => setApiKeyValue(e.target.value)} | 1755 | <SelectValue /> |
| 1557 | placeholder="sk_live_..." | 1756 | </SelectTrigger> |
| 1558 | className="h-8 text-xs font-mono bg-background" | 1757 | <SelectContent> |
| 1559 | /> | 1758 | <SelectItem value="new" className="text-xs">Create new connection</SelectItem> |
| 1560 | </div> | 1759 | <SelectItem value="existing" className="text-xs">Use existing connection</SelectItem> |
| 1561 | ) : selectedAuth.type === "basic" ? ( | 1760 | </SelectContent> |
| 1562 | <div className="grid grid-cols-2 gap-3"> | 1761 | </Select> |
| 1563 | <div className="space-y-1.5"> | | |
| 1564 | <Label className="text-xs text-muted-foreground">Username</Label> | | |
| 1565 | <Input | | |
| 1566 | value={basicUsername} | | |
| 1567 | onChange={(e) => setBasicUsername(e.target.value)} | | |
| 1568 | className="h-8 text-xs font-mono bg-background" | | |
| 1569 | /> | | |
| 1570 | </div> | | |
| 1571 | <div className="space-y-1.5"> | | |
| 1572 | <Label className="text-xs text-muted-foreground">Password</Label> | | |
| 1573 | <Input | | |
| 1574 | type="password" | | |
| 1575 | value={basicPassword} | | |
| 1576 | onChange={(e) => setBasicPassword(e.target.value)} | | |
| 1577 | className="h-8 text-xs font-mono bg-background" | | |
| 1578 | /> | | |
| 1579 | </div> | | |
| 1580 | </div> | 1762 | </div> |
| 1581 | ) : ( | 1763 | )} |
| | 1764 | |
| | 1765 | {!editing && connectionMode === "existing" && ( |
| 1582 | <div className="space-y-1.5"> | 1766 | <div className="space-y-1.5"> |
| 1583 | <Label className="text-xs text-muted-foreground">Bearer Token</Label> | 1767 | <Label className="text-xs text-muted-foreground">Existing Connection</Label> |
| 1584 | <Input | 1768 | <Select value={existingConnectionId} onValueChange={setExistingConnectionId}> |
| 1585 | type="password" | 1769 | <SelectTrigger className="h-8 text-xs bg-background"> |
| 1586 | value={managedToken} | 1770 | <SelectValue placeholder="Select a connection" /> |
| 1587 | onChange={(e) => setManagedToken(e.target.value)} | 1771 | </SelectTrigger> |
| 1588 | placeholder="ghp_..." | 1772 | <SelectContent> |
| 1589 | className="h-8 text-xs font-mono bg-background" | 1773 | {compatibleConnectionOptions.map((connection) => ( |
| 1590 | /> | 1774 | <SelectItem key={connection.id} value={connection.id} className="text-xs"> |
| | 1775 | {connectionDisplayName(sources, connection)} ({connection.sourceKeys.size} source{connection.sourceKeys.size === 1 ? "" : "s"}) |
| | 1776 | </SelectItem> |
| | 1777 | ))} |
| | 1778 | </SelectContent> |
| | 1779 | </Select> |
| | 1780 | {compatibleConnectionOptions.length === 0 && ( |
| | 1781 | <p className="text-[10px] text-muted-foreground">No compatible existing connections for this scope.</p> |
| | 1782 | )} |
| 1591 | </div> | 1783 | </div> |
| 1592 | )} | 1784 | )} |
| 1593 | | 1785 | |
| 1594 | <Collapsible open={advancedMode} onOpenChange={handleAdvancedModeChange}> | 1786 | {(editing || connectionMode === "new") && ( |
| 1595 | <CollapsibleTrigger asChild> | 1787 | <> |
| 1596 | <Button variant="outline" size="sm" className="h-7 text-[11px]"> | 1788 | {editing && ( |
| 1597 | Advanced JSON | 1789 | <p className="text-[11px] text-muted-foreground"> |
| 1598 | <ChevronRight className={cn("ml-1.5 h-3 w-3 transition-transform", advancedMode && "rotate-90")} /> | 1790 | Stored secret is hidden. Enter a new value to rotate it, or leave fields blank to keep it. |
| 1599 | </Button> | 1791 | </p> |
| 1600 | </CollapsibleTrigger> | 1792 | )} |
| 1601 | <CollapsibleContent className="mt-2 space-y-1.5"> | 1793 | |
| 1602 | <Label className="text-xs text-muted-foreground">Override Secret JSON</Label> | 1794 | {selectedAuth.type === "none" ? ( |
| 1603 | <Textarea | 1795 | <p className="text-[11px] text-terminal-amber">This source does not currently require auth.</p> |
| 1604 | value={secretJsonText} | 1796 | ) : selectedAuth.type === "mixed" ? ( |
| 1605 | onChange={(e) => setSecretJsonText(e.target.value)} | 1797 | <p className="text-[11px] text-terminal-amber"> |
| 1606 | rows={6} | 1798 | This source has mixed auth requirements. Link an existing connection for now. |
| 1607 | className="text-xs font-mono bg-background" | 1799 | </p> |
| 1608 | /> | 1800 | ) : selectedAuth.type === "apiKey" ? ( |
| 1609 | </CollapsibleContent> | 1801 | <div className="space-y-1.5"> |
| 1610 | </Collapsible> | 1802 | <Label className="text-xs text-muted-foreground">API Key Value</Label> |
| | 1803 | <Input |
| | 1804 | value={apiKeyValue} |
| | 1805 | onChange={(e) => setApiKeyValue(e.target.value)} |
| | 1806 | placeholder="sk_live_..." |
| | 1807 | className="h-8 text-xs font-mono bg-background" |
| | 1808 | /> |
| | 1809 | </div> |
| | 1810 | ) : selectedAuth.type === "basic" ? ( |
| | 1811 | <div className="grid grid-cols-2 gap-3"> |
| | 1812 | <div className="space-y-1.5"> |
| | 1813 | <Label className="text-xs text-muted-foreground">Username</Label> |
| | 1814 | <Input |
| | 1815 | value={basicUsername} |
| | 1816 | onChange={(e) => setBasicUsername(e.target.value)} |
| | 1817 | className="h-8 text-xs font-mono bg-background" |
| | 1818 | /> |
| | 1819 | </div> |
| | 1820 | <div className="space-y-1.5"> |
| | 1821 | <Label className="text-xs text-muted-foreground">Password</Label> |
| | 1822 | <Input |
| | 1823 | type="password" |
| | 1824 | value={basicPassword} |
| | 1825 | onChange={(e) => setBasicPassword(e.target.value)} |
| | 1826 | className="h-8 text-xs font-mono bg-background" |
| | 1827 | /> |
| | 1828 | </div> |
| | 1829 | </div> |
| | 1830 | ) : ( |
| | 1831 | <div className="space-y-1.5"> |
| | 1832 | <Label className="text-xs text-muted-foreground">Bearer Token</Label> |
| | 1833 | <Input |
| | 1834 | type="password" |
| | 1835 | value={tokenValue} |
| | 1836 | onChange={(e) => setTokenValue(e.target.value)} |
| | 1837 | placeholder="ghp_..." |
| | 1838 | className="h-8 text-xs font-mono bg-background" |
| | 1839 | /> |
| | 1840 | </div> |
| | 1841 | )} |
| | 1842 | </> |
| | 1843 | )} |
| | 1844 | |
| | 1845 | <div className="space-y-1.5"> |
| | 1846 | <Label className="text-xs text-muted-foreground">Custom Headers (optional)</Label> |
| | 1847 | <Textarea |
| | 1848 | value={customHeadersText} |
| | 1849 | onChange={(e) => setCustomHeadersText(e.target.value)} |
| | 1850 | rows={4} |
| | 1851 | placeholder="x-tenant-id: acme\nx-env: staging" |
| | 1852 | className="text-xs font-mono bg-background" |
| | 1853 | /> |
| | 1854 | </div> |
| 1611 | | 1855 | |
| 1612 | <Button onClick={handleSave} disabled={saving} className="w-full h-9" size="sm"> | 1856 | <Button onClick={handleSave} disabled={saving} className="w-full h-9" size="sm"> |
| 1613 | {saving ? "Saving..." : editing ? "Update Credential" : "Save Credential"} | 1857 | {saving ? "Saving..." : editing ? "Update Connection" : connectionMode === "existing" ? "Link Connection" : "Save Connection"} |
| 1614 | </Button> | 1858 | </Button> |
| 1615 | </div> | 1859 | </div> |
| 1616 | </DialogContent> | 1860 | </DialogContent> |
| @@ -1625,6 +1869,9 @@ function CredentialsPanel({ · 8 unmodified lines |
| 1625 | | 1869 | |
| 1626 | export function ToolsView({ initialSource }: { initialSource?: string | null }) { | 1870 | export function ToolsView({ initialSource }: { initialSource?: string | null }) { |
| 1627 | const { context, loading: sessionLoading } = useSession(); | 1871 | const { context, loading: sessionLoading } = useSession(); |
| | 1872 | const [selectedSource, setSelectedSource] = useState<string | null>(initialSource ?? null); |
| | 1873 | const [activeTab, setActiveTab] = useState<"catalog" | "credentials" | "mcp">("catalog"); |
| | 1874 | const [focusCredentialSourceKey, setFocusCredentialSourceKey] = useState<string | null>(null); |
| 1628 | | 1875 | |
| 1629 | const sources = useQuery( | 1876 | const sources = useQuery( |
| 1630 | convexApi.workspace.listToolSources, | 1877 | convexApi.workspace.listToolSources, |
| @@ -1637,9 +1884,20 @@ export function ToolsView({ initialSource }: { initialSource?: string | null }) · 6 unmodified lines |
| 1637 | convexApi.workspace.listCredentials, | 1884 | convexApi.workspace.listCredentials, |
| 1638 | context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip", | 1885 | context ? { workspaceId: context.workspaceId, sessionId: context.sessionId } : "skip", |
| 1639 | ); | 1886 | ); |
| | 1887 | const credentialItems: CredentialRecord[] = credentials ?? []; |
| 1640 | const credentialsLoading = !!context && credentials === undefined; | 1888 | const credentialsLoading = !!context && credentials === undefined; |
| 1641 | | 1889 | |
| 1642 | const { tools, warnings, sourceQuality, loading: toolsLoading } = useWorkspaceTools(context ?? null); | 1890 | const { |
| | 1891 | tools, |
| | 1892 | warnings, |
| | 1893 | sourceQuality, |
| | 1894 | sourceAuthProfiles, |
| | 1895 | loadingTools, |
| | 1896 | refreshingTools, |
| | 1897 | } = useWorkspaceTools(context ?? null); |
| | 1898 | const selectedSourceRecord = selectedSource |
| | 1899 | ? sourceItems.find((source) => source.name === selectedSource) ?? null |
| | 1900 | : null; |
| 1643 | | 1901 | |
| 1644 | if (sessionLoading) { | 1902 | if (sessionLoading) { |
| 1645 | return ( | 1903 | return ( |
| @@ -1654,108 +1912,113 @@ export function ToolsView({ initialSource }: { initialSource?: string | null }) · 8 unmodified lines |
| 1654 | <div className="flex h-full min-h-0 flex-col gap-6"> | 1912 | <div className="flex h-full min-h-0 flex-col gap-6"> |
| 1655 | <PageHeader | 1913 | <PageHeader |
| 1656 | title="Tools" | 1914 | title="Tools" |
| 1657 | description="Manage sources, auth, credentials, and available tools" | 1915 | description="Manage sources, auth, connections, and available tools" |
| 1658 | /> | 1916 | /> |
| 1659 | | 1917 | |
| 1660 | <Tabs | 1918 | <Tabs |
| 1661 | defaultValue={initialSource ? "inventory" : "sources"} | 1919 | value={activeTab} |
| | 1920 | onValueChange={(value) => setActiveTab(value as "catalog" | "credentials" | "mcp")} |
| 1662 | className="w-full min-h-0 flex-1" | 1921 | className="w-full min-h-0 flex-1" |
| 1663 | > | 1922 | > |
| 1664 | <TabsList className="bg-muted/50 h-9"> | 1923 | <TabsList className="bg-muted/50 h-9"> |
| 1665 | <TabsTrigger value="sources" className="text-xs data-[state=active]:bg-background"> | 1924 | <TabsTrigger value="catalog" className="text-xs data-[state=active]:bg-background"> |
| 1666 | Sources | 1925 | Catalog |
| 1667 | {sources && ( | 1926 | <span className="ml-1.5 text-[10px] font-mono text-muted-foreground"> |
| 1668 | <span className="ml-1.5 text-[10px] font-mono text-muted-foreground"> | 1927 | {loadingTools ? "..." : tools.length} |
| 1669 | {sourceItems.length} | 1928 | </span> |
| 1670 | </span> | | |
| 1671 | )} | | |
| 1672 | </TabsTrigger> | 1929 | </TabsTrigger> |
| 1673 | <TabsTrigger value="credentials" className="text-xs data-[state=active]:bg-background"> | 1930 | <TabsTrigger value="credentials" className="text-xs data-[state=active]:bg-background"> |
| 1674 | Credentials | 1931 | Connections |
| 1675 | {credentials && ( | 1932 | {credentials && ( |
| 1676 | <span className="ml-1.5 text-[10px] font-mono text-muted-foreground"> | 1933 | <span className="ml-1.5 text-[10px] font-mono text-muted-foreground"> |
| 1677 | {credentials.length} | 1934 | {new Set(credentialItems.map((credential) => credential.id)).size} |
| 1678 | </span> | 1935 | </span> |
| 1679 | )} | 1936 | )} |
| 1680 | </TabsTrigger> | 1937 | </TabsTrigger> |
| 1681 | <TabsTrigger value="inventory" className="text-xs data-[state=active]:bg-background"> | | |
| 1682 | Inventory | | |
| 1683 | <span className="ml-1.5 text-[10px] font-mono text-muted-foreground"> | | |
| 1684 | {toolsLoading ? "…" : tools.length} | | |
| 1685 | </span> | | |
| 1686 | </TabsTrigger> | | |
| 1687 | <TabsTrigger value="mcp" className="text-xs data-[state=active]:bg-background"> | 1938 | <TabsTrigger value="mcp" className="text-xs data-[state=active]:bg-background"> |
| 1688 | MCP Setup | 1939 | MCP Setup |
| 1689 | </TabsTrigger> | 1940 | </TabsTrigger> |
| 1690 | </TabsList> | 1941 | </TabsList> |
| 1691 | | 1942 | |
| 1692 | <TabsContent value="sources" className="mt-4"> | 1943 | <TabsContent value="catalog" className="mt-4 min-h-0"> |
| 1693 | <Card className="bg-card border-border"> | 1944 | <Card className="bg-card border-border min-h-0 flex flex-col"> |
| 1694 | <CardHeader className="pb-3"> | 1945 | <CardHeader className="pb-3"> |
| 1695 | <div className="flex items-center justify-between"> | 1946 | <div className="flex items-center justify-between gap-3"> |
| 1696 | <CardTitle className="text-sm font-medium flex items-center gap-2"> | 1947 | <CardTitle className="text-sm font-medium flex items-center gap-2"> |
| 1697 | <Server className="h-4 w-4 text-muted-foreground" /> | 1948 | <Globe className="h-4 w-4 text-muted-foreground" /> |
| 1698 | Tool Sources | 1949 | Tools + Sources |
| | 1950 | <span className="text-[10px] font-mono bg-muted text-muted-foreground px-1.5 py-0.5 rounded"> |
| | 1951 | {loadingTools ? "..." : tools.length} |
| | 1952 | </span> |
| 1699 | </CardTitle> | 1953 | </CardTitle> |
| 1700 | <AddSourceDialog existingSourceNames={new Set(sourceItems.map((s) => s.name))} /> | 1954 | <div className="flex items-center gap-2"> |
| | 1955 | {selectedSource ? ( |
| | 1956 | <Button variant="outline" size="sm" className="h-7 text-[11px]" onClick={() => setSelectedSource(null)}> |
| | 1957 | Clear source filter |
| | 1958 | </Button> |
| | 1959 | ) : null} |
| | 1960 | <AddSourceDialog |
| | 1961 | existingSourceNames={new Set(sourceItems.map((s) => s.name))} |
| | 1962 | onSourceAdded={(source) => { |
| | 1963 | setActiveTab("credentials"); |
| | 1964 | setSelectedSource(source.name); |
| | 1965 | const key = sourceKeyForSource(source); |
| | 1966 | if (key) { |
| | 1967 | setFocusCredentialSourceKey(key); |
| | 1968 | } |
| | 1969 | }} |
| | 1970 | /> |
| | 1971 | </div> |
| 1701 | </div> | 1972 | </div> |
| | 1973 | <p className="text-[11px] text-muted-foreground"> |
| | 1974 | {selectedSource |
| | 1975 | ? `Filtering and managing ${selectedSource}.` |
| | 1976 | : "Source management and tool inventory are unified here."} |
| | 1977 | </p> |
| 1702 | </CardHeader> | 1978 | </CardHeader> |
| 1703 | <CardContent className="pt-0"> | 1979 | <CardContent className="pt-0 min-h-0 flex-1 flex flex-col gap-3"> |
| 1704 | {sourcesLoading ? ( | 1980 | {sourcesLoading ? ( |
| 1705 | <div className="space-y-2"> | 1981 | <div className="space-y-2"> |
| 1706 | {Array.from({ length: 3 }).map((_, i) => ( | 1982 | {Array.from({ length: 2 }).map((_, i) => ( |
| 1707 | <Skeleton key={i} className="h-16" /> | 1983 | <Skeleton key={i} className="h-16" /> |
| 1708 | ))} | 1984 | ))} |
| 1709 | </div> | 1985 | </div> |
| 1710 | ) : sourceItems.length === 0 ? ( | 1986 | ) : null} |
| 1711 | <div className="flex flex-col items-center justify-center py-12 gap-3"> | 1987 | |
| 1712 | <div className="h-12 w-12 rounded-full bg-muted flex items-center justify-center"> | 1988 | {!sourcesLoading && sourceItems.length === 0 ? ( |
| 1713 | <Wrench className="h-6 w-6 text-muted-foreground/40" /> | 1989 | <div className="rounded-md border border-border/70 bg-muted/30 px-3 py-2.5"> |
| 1714 | </div> | 1990 | <p className="text-[11px] text-muted-foreground"> |
| 1715 | <p className="text-sm text-muted-foreground"> | 1991 | No external sources yet. Add MCP, OpenAPI, or GraphQL to expand available tools. |
| 1716 | No external tool sources | | |
| 1717 | </p> | | |
| 1718 | <p className="text-[11px] text-muted-foreground/60"> | | |
| 1719 | Add MCP, OpenAPI, or GraphQL sources to extend available tools | | |
| 1720 | </p> | 1992 | </p> |
| 1721 | </div> | 1993 | </div> |
| 1722 | ) : ( | 1994 | ) : null} |
| 1723 | <div className="space-y-2"> | 1995 | |
| 1724 | {warnings.length > 0 && ( | 1996 | {selectedSourceRecord ? ( |
| 1725 | <div className="rounded-md border border-terminal-amber/30 bg-terminal-amber/10 px-3 py-2.5"> | 1997 | <div className="space-y-1.5"> |
| 1726 | <div className="flex items-center gap-1.5 text-[11px] font-medium text-terminal-amber"> | 1998 | <p className="text-[10px] uppercase tracking-widest text-muted-foreground">Selected source</p> |
| 1727 | <AlertTriangle className="h-3.5 w-3.5" /> | 1999 | <SourceCard |
| 1728 | Source load warnings ({warnings.length}) | 2000 | source={selectedSourceRecord} |
| 1729 | </div> | 2001 | quality={sourceQuality[toolSourceLabelForSource(selectedSourceRecord)]} |
| 1730 | <div className="mt-1.5 space-y-1"> | 2002 | qualityLoading={selectedSourceRecord.type === "openapi" && !sourceQuality[toolSourceLabelForSource(selectedSourceRecord)] && refreshingTools} |
| 1731 | {warnings.slice(0, 3).map((warning: string, i: number) => ( | 2003 | credentialStats={credentialStatsForSource(selectedSourceRecord, credentialItems)} |
| 1732 | <p key={`${warning}-${i}`} className="text-[11px] text-terminal-amber/90"> | 2004 | sourceAuthProfiles={sourceAuthProfiles} |
| 1733 | {warning} | 2005 | selected |
| 1734 | </p> | 2006 | onFocusSource={setSelectedSource} |
| 1735 | ))} | 2007 | /> |
| 1736 | {warnings.length > 3 && ( | | |
| 1737 | <p className="text-[10px] text-terminal-amber/80"> | | |
| 1738 | +{warnings.length - 3} more warning{warnings.length - 3 === 1 ? "" : "s"} | | |
| 1739 | </p> | | |
| 1740 | )} | | |
| 1741 | </div> | | |
| 1742 | </div> | | |
| 1743 | )} | | |
| 1744 | {sourceItems.map((s) => { | | |
| 1745 | const quality = sourceQuality[toolSourceLabelForSource(s)]; | | |
| 1746 | const credentialStats = credentialStatsForSource(s, credentials ?? []); | | |
| 1747 | return ( | | |
| 1748 | <SourceCard | | |
| 1749 | key={s.id} | | |
| 1750 | source={s} | | |
| 1751 | quality={quality} | | |
| 1752 | qualityLoading={toolsLoading} | | |
| 1753 | credentialStats={credentialStats} | | |
| 1754 | /> | | |
| 1755 | ); | | |
| 1756 | })} | | |
| 1757 | </div> | 2008 | </div> |
| 1758 | )} | 2009 | ) : null} |
| | 2010 | |
| | 2011 | <div className="min-h-0 flex-1"> |
| | 2012 | <ToolExplorer |
| | 2013 | tools={tools} |
| | 2014 | sources={sourceItems} |
| | 2015 | loading={loadingTools} |
| | 2016 | warnings={warnings} |
| | 2017 | initialSource={initialSource} |
| | 2018 | activeSource={selectedSource} |
| | 2019 | onActiveSourceChange={setSelectedSource} |
| | 2020 | /> |
| | 2021 | </div> |
| 1759 | </CardContent> | 2022 | </CardContent> |
| 1760 | </Card> | 2023 | </Card> |
| 1761 | </TabsContent> | 2024 | </TabsContent> |
| @@ -1763,18 +2026,11 @@ export function ToolsView({ initialSource }: { initialSource?: string | null }) · 1 unmodified line |
| 1763 | <TabsContent value="credentials" className="mt-4"> | 2026 | <TabsContent value="credentials" className="mt-4"> |
| 1764 | <CredentialsPanel | 2027 | <CredentialsPanel |
| 1765 | sources={sourceItems} | 2028 | sources={sourceItems} |
| 1766 | credentials={credentials ?? []} | 2029 | credentials={credentialItems} |
| | 2030 | sourceAuthProfiles={sourceAuthProfiles} |
| 1767 | loading={credentialsLoading || sourcesLoading} | 2031 | loading={credentialsLoading || sourcesLoading} |
| 1768 | /> | 2032 | focusSourceKey={focusCredentialSourceKey} |
| 1769 | </TabsContent> | 2033 | onFocusHandled={() => setFocusCredentialSourceKey(null)} |
| 1770 | | | |
| 1771 | <TabsContent value="inventory" className="mt-4 min-h-0"> | | |
| 1772 | <ToolExplorer | | |
| 1773 | tools={tools} | | |
| 1774 | sources={sourceItems} | | |
| 1775 | loading={toolsLoading} | | |
| 1776 | warnings={warnings} | | |
| 1777 | initialSource={initialSource} | | |
| 1778 | /> | 2034 | /> |
| 1779 | </TabsContent> | 2035 | </TabsContent> |
| 1780 | | 2036 | |