| @@ -9,8 +9,10 @@ import { · 8 unmodified lines |
| 9 | 9 | defineExecutorPluginHttpApiClient, |
| 10 | 10 | useAtomValue, |
| 11 | 11 | useAtomSet, |
| 12 | + useCreateSecret, |
| 12 | 13 | useExecutorMutation, |
| 13 | 14 | usePrefetchToolDetail, |
| 15 | + useSecrets, |
| 14 | 16 | useSourceDiscovery, |
| 15 | 17 | useSourceInspection, |
| 16 | 18 | useSourceToolDetail, |
| @@ -62,6 +64,93 @@ const defaultOpenApiInput = (): OpenApiConnectInput => ({ · 45 unmodified lines |
| 62 | 64 | }, |
| 63 | 65 | }); |
| 64 | 66 | |
| 67 | +const DEFAULT_BEARER_HEADER_NAME = "Authorization"; |
| 68 | +const DEFAULT_BEARER_PREFIX = "Bearer "; |
| 69 | +const CREATE_SECRET_VALUE = "__create_openapi_secret__"; |
| 70 | + |
| 71 | +const presetString = ( |
| 72 | + search: Record<string, unknown>, |
| 73 | + key: string, |
| 74 | +): string | null => { |
| 75 | + const value = search[key]; |
| 76 | + return typeof value === "string" && value.trim().length > 0 |
| 77 | + ? value.trim() |
| 78 | + : null; |
| 79 | +}; |
| 80 | + |
| 81 | +const firstSearchString = ( |
| 82 | + search: Record<string, unknown>, |
| 83 | + keys: ReadonlyArray<string>, |
| 84 | +): string | null => { |
| 85 | + for (const key of keys) { |
| 86 | + const value = presetString(search, key); |
| 87 | + if (value) { |
| 88 | + return value; |
| 89 | + } |
| 90 | + } |
| 91 | + |
| 92 | + return null; |
| 93 | +}; |
| 94 | + |
| 95 | +const looksLikeOpenApiSpecUrl = (value: string): boolean => { |
| 96 | + const lower = value.trim().toLowerCase(); |
| 97 | + if (!/^https?:\/\//.test(lower)) { |
| 98 | + return false; |
| 99 | + } |
| 100 | + |
| 101 | + return ( |
| 102 | + lower.endsWith(".json") |
| 103 | + || lower.endsWith(".yaml") |
| 104 | + || lower.endsWith(".yml") |
| 105 | + || lower.includes("/openapi") |
| 106 | + || lower.includes("openapi.") |
| 107 | + || lower.includes("/swagger") |
| 108 | + || lower.includes("swagger.") |
| 109 | + || lower.includes("/api-docs") |
| 110 | + ); |
| 111 | +}; |
| 112 | + |
| 113 | +const inferOpenApiUrls = (value: string): { |
| 114 | + specUrl: string; |
| 115 | + baseUrl: string | null; |
| 116 | +} => { |
| 117 | + const trimmed = value.trim(); |
| 118 | + if (!trimmed) { |
| 119 | + return { |
| 120 | + specUrl: defaultOpenApiInput().specUrl, |
| 121 | + baseUrl: defaultOpenApiInput().baseUrl, |
| 122 | + }; |
| 123 | + } |
| 124 | + |
| 125 | + if (looksLikeOpenApiSpecUrl(trimmed)) { |
| 126 | + return { |
| 127 | + specUrl: trimmed, |
| 128 | + baseUrl: null, |
| 129 | + }; |
| 130 | + } |
| 131 | + |
| 132 | + const normalizedBaseUrl = trimmed.replace(/\/+$/, ""); |
| 133 | + return { |
| 134 | + specUrl: `${normalizedBaseUrl}/openapi.json`, |
| 135 | + baseUrl: normalizedBaseUrl, |
| 136 | + }; |
| 137 | +}; |
| 138 | + |
| 139 | +const openApiInputFromSearch = ( |
| 140 | + search: Record<string, unknown>, |
| 141 | +): OpenApiConnectInput => { |
| 142 | + const defaults = defaultOpenApiInput(); |
| 143 | + const genericUrl = firstSearchString(search, ["inputUrl", "pastedUrl", "url"]); |
| 144 | + const inferred = genericUrl ? inferOpenApiUrls(genericUrl) : defaults; |
| 145 | + |
| 146 | + return { |
| 147 | + ...defaults, |
| 148 | + name: presetString(search, "presetName") ?? defaults.name, |
| 149 | + specUrl: presetString(search, "presetSpecUrl") ?? inferred.specUrl, |
| 150 | + baseUrl: presetString(search, "presetBaseUrl") ?? inferred.baseUrl, |
| 151 | + }; |
| 152 | +}; |
| 153 | + |
| 65 | 154 | const getOpenApiHttpClient = defineExecutorPluginHttpApiClient<"OpenApiReactHttpClient">()( |
| 66 | 155 | "OpenApiReactHttpClient", |
| 67 | 156 | [openApiHttpApiExtension] as const, |
| @@ -72,13 +161,8 @@ const Section = (props: { · 4 unmodified lines |
| 72 | 161 | title: string; |
| 73 | 162 | children: ReactNode; |
| 74 | 163 | }) => ( |
| 75 | | − <section className="rounded-xl border border-border/70 bg-card/40 p-4"> |
| 76 | | − <div className="mb-3 flex items-center gap-2"> |
| 77 | | − <h2 className="text-sm font-semibold">{props.title}</h2> |
| 78 | | − <span className="inline-flex items-center rounded-full border border-transparent bg-secondary px-2 py-0.5 text-[10px] font-medium uppercase tracking-wide text-secondary-foreground"> |
| 79 | | − Plugin |
| 80 | | − </span> |
| 81 | | − </div> |
| 164 | + <section> |
| 165 | + <h2 className="mb-3 text-sm font-semibold text-foreground">{props.title}</h2> |
| 82 | 166 | {props.children} |
| 83 | 167 | </section> |
| 84 | 168 | ); |
| @@ -174,9 +258,26 @@ const secretValue = (input: OpenApiConnectInput["auth"]): string => · 89 unmodified lines |
| 174 | 258 | ? JSON.stringify(input.tokenSecretRef) |
| 175 | 259 | : ""; |
| 176 | 260 | |
| 261 | +const bearerHeaderNameValue = (input: OpenApiConnectInput["auth"]): string => |
| 262 | + input.kind === "bearer" |
| 263 | + ? input.headerName?.trim() || DEFAULT_BEARER_HEADER_NAME |
| 264 | + : DEFAULT_BEARER_HEADER_NAME; |
| 265 | + |
| 266 | +const bearerPrefixValue = (input: OpenApiConnectInput["auth"]): string => |
| 267 | + input.kind === "bearer" |
| 268 | + ? input.prefix ?? DEFAULT_BEARER_PREFIX |
| 269 | + : DEFAULT_BEARER_PREFIX; |
| 270 | + |
| 271 | +const trimToNull = (value: string): string | null => { |
| 272 | + const trimmed = value.trim(); |
| 273 | + return trimmed.length > 0 ? trimmed : null; |
| 274 | +}; |
| 275 | + |
| 177 | 276 | const authFromSecretValue = ( |
| 178 | 277 | authKind: OpenApiConnectInput["auth"]["kind"], |
| 179 | 278 | value: string, |
| 279 | + headerName: string, |
| 280 | + prefix: string, |
| 180 | 281 | ): OpenApiConnectInput["auth"] => { |
| 181 | 282 | if (authKind === "none") { |
| 182 | 283 | return { |
| @@ -191,23 +292,138 @@ const authFromSecretValue = ( · 8 unmodified lines |
| 191 | 292 | return { |
| 192 | 293 | kind: "bearer", |
| 193 | 294 | tokenSecretRef: JSON.parse(value) as OpenApiConnectInput["auth"] & { tokenSecretRef: never }["tokenSecretRef"], |
| 295 | + headerName: trimToNull(headerName), |
| 296 | + prefix: prefix === DEFAULT_BEARER_PREFIX ? null : prefix, |
| 194 | 297 | }; |
| 195 | 298 | }; |
| 196 | 299 | |
| 197 | | −const useAvailableSecrets = ( |
| 198 | | − openApiHttpClient: ReturnType<typeof getOpenApiHttpClient>, |
| 199 | | −) => { |
| 200 | | − const secretsResult = useAtomValue( |
| 201 | | − openApiHttpClient.query("local", "listSecrets", { |
| 202 | | − reactivityKeys: { |
| 203 | | − secrets: [], |
| 204 | | − }, |
| 205 | | − timeToLive: "1 minute", |
| 206 | | − }), |
| 207 | | − ); |
| 300 | +function SecretSelectOrCreateField(props: { |
| 301 | + label: string; |
| 302 | + value: string; |
| 303 | + emptyLabel: string; |
| 304 | + draftNamePlaceholder: string; |
| 305 | + draftValuePlaceholder: string; |
| 306 | + onChange: (value: string) => void; |
| 307 | +}) { |
| 308 | + const secrets = useSecrets(); |
| 309 | + const createSecret = useCreateSecret(); |
| 310 | + const [draftName, setDraftName] = useState(""); |
| 311 | + const [draftValue, setDraftValue] = useState(""); |
| 312 | + const [createError, setCreateError] = useState<string | null>(null); |
| 313 | + const [showCreate, setShowCreate] = useState(false); |
| 314 | + |
| 315 | + const handleSelectChange = (nextValue: string) => { |
| 316 | + if (nextValue === CREATE_SECRET_VALUE) { |
| 317 | + setShowCreate(true); |
| 318 | + props.onChange(""); |
| 319 | + return; |
| 320 | + } |
| 208 | 321 | |
| 209 | | − return Result.isSuccess(secretsResult) ? secretsResult.value : []; |
| 210 | | −}; |
| 322 | + setShowCreate(false); |
| 323 | + setCreateError(null); |
| 324 | + props.onChange(nextValue); |
| 325 | + }; |
| 326 | + |
| 327 | + const handleCreate = async () => { |
| 328 | + const trimmedName = draftName.trim(); |
| 329 | + if (!trimmedName) { |
| 330 | + setCreateError("Secret name is required."); |
| 331 | + return; |
| 332 | + } |
| 333 | + if (!draftValue.trim()) { |
| 334 | + setCreateError("Secret value is required."); |
| 335 | + return; |
| 336 | + } |
| 337 | + |
| 338 | + try { |
| 339 | + setCreateError(null); |
| 340 | + const created = await createSecret.mutateAsync({ |
| 341 | + name: trimmedName, |
| 342 | + value: draftValue, |
| 343 | + }); |
| 344 | + props.onChange(JSON.stringify({ |
| 345 | + providerId: created.providerId, |
| 346 | + handle: created.id, |
| 347 | + })); |
| 348 | + setDraftName(""); |
| 349 | + setDraftValue(""); |
| 350 | + setShowCreate(false); |
| 351 | + } catch (cause) { |
| 352 | + setCreateError(cause instanceof Error ? cause.message : "Failed creating secret."); |
| 353 | + } |
| 354 | + }; |
| 355 | + |
| 356 | + return ( |
| 357 | + <div className="grid gap-2"> |
| 358 | + <span className="text-xs font-medium text-foreground">{props.label}</span> |
| 359 | + <select |
| 360 | + value={showCreate ? CREATE_SECRET_VALUE : props.value} |
| 361 | + onChange={(event) => handleSelectChange(event.target.value)} |
| 362 | + className="h-9 w-full rounded-lg border border-input bg-background px-3 text-sm text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 363 | + > |
| 364 | + <option value="">{props.emptyLabel}</option> |
| 365 | + {secrets.status === "ready" && |
| 366 | + secrets.data.map((secret) => ( |
| 367 | + <option |
| 368 | + key={`${secret.providerId}:${secret.id}`} |
| 369 | + value={JSON.stringify({ |
| 370 | + providerId: secret.providerId, |
| 371 | + handle: secret.id, |
| 372 | + })} |
| 373 | + > |
| 374 | + {secret.name ?? secret.id} |
| 375 | + </option> |
| 376 | + ))} |
| 377 | + <option value={CREATE_SECRET_VALUE}>Create new secret</option> |
| 378 | + </select> |
| 379 | + |
| 380 | + {showCreate && ( |
| 381 | + <div className="space-y-3 border-l-2 border-border pl-4"> |
| 382 | + <input |
| 383 | + value={draftName} |
| 384 | + onChange={(event) => setDraftName(event.target.value)} |
| 385 | + placeholder={props.draftNamePlaceholder} |
| 386 | + className="h-9 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 387 | + /> |
| 388 | + <textarea |
| 389 | + value={draftValue} |
| 390 | + onChange={(event) => setDraftValue(event.target.value)} |
| 391 | + rows={3} |
| 392 | + placeholder={props.draftValuePlaceholder} |
| 393 | + className="w-full rounded-lg border border-input bg-background px-3 py-2 font-mono text-xs outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 394 | + /> |
| 395 | + {createError && ( |
| 396 | + <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-3 py-2 text-xs text-destructive"> |
| 397 | + {createError} |
| 398 | + </div> |
| 399 | + )} |
| 400 | + <div className="flex items-center gap-2"> |
| 401 | + <button |
| 402 | + type="button" |
| 403 | + onClick={() => { |
| 404 | + void handleCreate(); |
| 405 | + }} |
| 406 | + disabled={createSecret.status === "pending"} |
| 407 | + className="inline-flex h-8 items-center justify-center rounded-lg bg-primary px-3 text-xs font-medium text-primary-foreground transition-opacity disabled:pointer-events-none disabled:opacity-50" |
| 408 | + > |
| 409 | + {createSecret.status === "pending" ? "Creating..." : "Create Secret"} |
| 410 | + </button> |
| 411 | + <button |
| 412 | + type="button" |
| 413 | + onClick={() => { |
| 414 | + setShowCreate(false); |
| 415 | + setCreateError(null); |
| 416 | + }} |
| 417 | + className="inline-flex h-8 items-center justify-center rounded-lg border border-input bg-card px-3 text-xs font-medium text-foreground transition-colors hover:bg-accent/50" |
| 418 | + > |
| 419 | + Cancel |
| 420 | + </button> |
| 421 | + </div> |
| 422 | + </div> |
| 423 | + )} |
| 424 | + </div> |
| 425 | + ); |
| 426 | +} |
| 211 | 427 | |
| 212 | 428 | function OpenApiSourceForm(props: { |
| 213 | 429 | mode: "create" | "edit"; |
| @@ -217,7 +433,6 @@ function OpenApiSourceForm(props: { · 3 unmodified lines |
| 217 | 433 | onSubmit: (input: OpenApiConnectInput) => Promise<void>; |
| 218 | 434 | }) { |
| 219 | 435 | const openApiHttpClient = getOpenApiHttpClient(); |
| 220 | | − const availableSecrets = useAvailableSecrets(openApiHttpClient); |
| 221 | 436 | const previewDocument = useAtomSet( |
| 222 | 437 | openApiHttpClient.mutation("openapi", "previewDocument"), |
| 223 | 438 | { mode: "promise" }, |
| @@ -232,6 +447,12 @@ function OpenApiSourceForm(props: { · 8 unmodified lines |
| 232 | 447 | const [tokenSecretRef, setTokenSecretRef] = useState( |
| 233 | 448 | secretValue(props.initialValue.auth), |
| 234 | 449 | ); |
| 450 | + const [authHeaderName, setAuthHeaderName] = useState( |
| 451 | + bearerHeaderNameValue(props.initialValue.auth), |
| 452 | + ); |
| 453 | + const [authPrefix, setAuthPrefix] = useState( |
| 454 | + bearerPrefixValue(props.initialValue.auth), |
| 455 | + ); |
| 235 | 456 | const [error, setError] = useState<string | null>(null); |
| 236 | 457 | const [preview, setPreview] = useState<OpenApiPreviewResponse | null>(null); |
| 237 | 458 | const [nameEdited, setNameEdited] = useState(false); |
| @@ -332,7 +553,12 @@ function OpenApiSourceForm(props: { · 94 unmodified lines |
| 332 | 553 | } |
| 333 | 554 | |
| 334 | 555 | try { |
| 335 | | − const auth = authFromSecretValue(authKind, tokenSecretRef); |
| 556 | + const auth = authFromSecretValue( |
| 557 | + authKind, |
| 558 | + tokenSecretRef, |
| 559 | + authHeaderName, |
| 560 | + authPrefix, |
| 561 | + ); |
| 336 | 562 | await submitMutation.mutateAsync({ |
| 337 | 563 | name: trimmedName, |
| 338 | 564 | specUrl: trimmedSpecUrl, |
| @@ -345,15 +571,11 @@ function OpenApiSourceForm(props: { · 6 unmodified lines |
| 345 | 571 | }; |
| 346 | 572 | |
| 347 | 573 | return ( |
| 348 | | − <div className="space-y-4"> |
| 574 | + <div className="space-y-6 rounded-xl border border-border p-6"> |
| 349 | 575 | <Section title="Connection"> |
| 350 | | − <p className="text-sm text-muted-foreground"> |
| 351 | | − This plugin owns its typed HTTP client and source payload shape. The app shell only |
| 352 | | − mounts the registered page. |
| 353 | | − </p> |
| 354 | | − <div className="mt-4 grid gap-4"> |
| 355 | | − <label className="grid gap-1.5"> |
| 356 | | − <span className="text-[12px] font-medium text-foreground">Name</span> |
| 576 | + <div className="grid gap-4"> |
| 577 | + <label className="grid gap-2"> |
| 578 | + <span className="text-xs font-medium text-foreground">Name</span> |
| 357 | 579 | <input |
| 358 | 580 | value={name} |
| 359 | 581 | onChange={(event) => { |
| @@ -361,12 +583,12 @@ function OpenApiSourceForm(props: { · 1 unmodified line |
| 361 | 583 | setName(event.target.value); |
| 362 | 584 | }} |
| 363 | 585 | placeholder="GitHub REST" |
| 364 | | − className="h-10 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 586 | + className="h-9 w-full rounded-lg border border-input bg-background px-3 text-sm text-foreground outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 365 | 587 | /> |
| 366 | 588 | </label> |
| 367 | 589 | |
| 368 | | − <label className="grid gap-1.5"> |
| 369 | | − <span className="text-[12px] font-medium text-foreground">Spec URL</span> |
| 590 | + <label className="grid gap-2"> |
| 591 | + <span className="text-xs font-medium text-foreground">Spec URL</span> |
| 370 | 592 | <input |
| 371 | 593 | value={specUrl} |
| 372 | 594 | onChange={(event) => setSpecUrl(event.target.value)} |
| @@ -374,12 +596,12 @@ function OpenApiSourceForm(props: { · 1 unmodified line |
| 374 | 596 | void runPreview({ mode: "manual" }); |
| 375 | 597 | }} |
| 376 | 598 | placeholder="https://example.com/openapi.json" |
| 377 | | − className="h-10 w-full rounded-lg border border-input bg-background px-3 font-mono text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 599 | + className="h-9 w-full rounded-lg border border-input bg-background px-3 font-mono text-xs text-foreground outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 378 | 600 | /> |
| 379 | 601 | </label> |
| 380 | 602 | |
| 381 | | − <label className="grid gap-1.5"> |
| 382 | | − <span className="text-[12px] font-medium text-foreground">Base URL</span> |
| 603 | + <label className="grid gap-2"> |
| 604 | + <span className="text-xs font-medium text-foreground">Base URL</span> |
| 383 | 605 | <input |
| 384 | 606 | value={baseUrl} |
| 385 | 607 | onChange={(event) => { |
| @@ -387,17 +609,17 @@ function OpenApiSourceForm(props: { · 1 unmodified line |
| 387 | 609 | setBaseUrl(event.target.value); |
| 388 | 610 | }} |
| 389 | 611 | placeholder="https://api.example.com" |
| 390 | | − className="h-10 w-full rounded-lg border border-input bg-background px-3 font-mono text-[12px] text-foreground outline-none transition-colors placeholder:text-muted-foreground/35 focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 612 | + className="h-9 w-full rounded-lg border border-input bg-background px-3 font-mono text-xs text-foreground outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 391 | 613 | /> |
| 392 | 614 | </label> |
| 393 | 615 | |
| 394 | | − <label className="grid gap-1.5"> |
| 395 | | − <span className="text-[12px] font-medium text-foreground">Auth</span> |
| 616 | + <label className="grid gap-2"> |
| 617 | + <span className="text-xs font-medium text-foreground">Auth</span> |
| 396 | 618 | <select |
| 397 | 619 | value={authKind} |
| 398 | 620 | onChange={(event) => |
| 399 | 621 | setAuthKind(event.target.value as OpenApiConnectInput["auth"]["kind"])} |
| 400 | | − className="h-10 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 622 | + className="h-9 w-full rounded-lg border border-input bg-background px-3 text-sm text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 401 | 623 | > |
| 402 | 624 | <option value="none">None</option> |
| 403 | 625 | <option value="bearer">Bearer Secret</option> |
| @@ -405,52 +627,54 @@ function OpenApiSourceForm(props: { · 1 unmodified line |
| 405 | 627 | </label> |
| 406 | 628 | |
| 407 | 629 | {authKind === "bearer" && ( |
| 408 | | − <label className="grid gap-1.5"> |
| 409 | | − <span className="text-[12px] font-medium text-foreground">Secret</span> |
| 410 | | − <select |
| 630 | + <div className="space-y-4 border-l-2 border-border pl-4"> |
| 631 | + <SecretSelectOrCreateField |
| 632 | + label="Secret" |
| 411 | 633 | value={tokenSecretRef} |
| 412 | | − onChange={(event) => setTokenSecretRef(event.target.value)} |
| 413 | | − className="h-10 w-full rounded-lg border border-input bg-background px-3 text-[13px] text-foreground outline-none transition-colors focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 414 | | − > |
| 415 | | − <option value="">Select a secret</option> |
| 416 | | − {availableSecrets.map((secret) => ( |
| 417 | | − <option |
| 418 | | − key={`${secret.providerId}:${secret.id}`} |
| 419 | | − value={JSON.stringify({ |
| 420 | | − providerId: secret.providerId, |
| 421 | | − handle: secret.id, |
| 422 | | − })} |
| 423 | | − > |
| 424 | | − {secret.name || secret.id} |
| 425 | | − </option> |
| 426 | | − ))} |
| 427 | | − </select> |
| 428 | | − </label> |
| 634 | + emptyLabel="Select a secret" |
| 635 | + draftNamePlaceholder="OpenAPI bearer token" |
| 636 | + draftValuePlaceholder="Paste the token value" |
| 637 | + onChange={setTokenSecretRef} |
| 638 | + /> |
| 639 | + |
| 640 | + <div className="grid gap-4 sm:grid-cols-2"> |
| 641 | + <label className="grid gap-2"> |
| 642 | + <span className="text-xs font-medium text-foreground">Header Name</span> |
| 643 | + <input |
| 644 | + value={authHeaderName} |
| 645 | + onChange={(event) => setAuthHeaderName(event.target.value)} |
| 646 | + placeholder={DEFAULT_BEARER_HEADER_NAME} |
| 647 | + className="h-9 w-full rounded-lg border border-input bg-background px-3 font-mono text-xs text-foreground outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 648 | + /> |
| 649 | + </label> |
| 650 | + |
| 651 | + <label className="grid gap-2"> |
| 652 | + <span className="text-xs font-medium text-foreground">Prefix</span> |
| 653 | + <input |
| 654 | + value={authPrefix} |
| 655 | + onChange={(event) => setAuthPrefix(event.target.value)} |
| 656 | + placeholder={DEFAULT_BEARER_PREFIX} |
| 657 | + className="h-9 w-full rounded-lg border border-input bg-background px-3 font-mono text-xs text-foreground outline-none transition-colors placeholder:text-muted-foreground/40 focus:border-ring focus:ring-1 focus:ring-ring/25" |
| 658 | + /> |
| 659 | + </label> |
| 660 | + </div> |
| 661 | + </div> |
| 429 | 662 | )} |
| 430 | 663 | </div> |
| 431 | 664 | </Section> |
| 432 | 665 | |
| 433 | 666 | <Section title="Preview"> |
| 434 | | − <p className="text-sm text-muted-foreground"> |
| 435 | | − Preview introspects the document and can pull out defaults like title and base URL from |
| 436 | | − the OpenAPI spec. |
| 437 | | − </p> |
| 438 | | − <div className="mt-3 flex items-center gap-3"> |
| 667 | + <div className="flex items-center gap-3"> |
| 439 | 668 | <button |
| 440 | 669 | type="button" |
| 441 | 670 | onClick={() => { |
| 442 | 671 | void runPreview({ mode: "manual" }); |
| 443 | 672 | }} |
| 444 | 673 | disabled={previewMutation.status === "pending" || submitMutation.status === "pending"} |
| 445 | | − className="inline-flex h-7 items-center justify-center rounded-md border border-input bg-transparent px-2.5 text-xs font-medium text-foreground disabled:pointer-events-none disabled:opacity-50" |
| 674 | + className="inline-flex h-9 items-center justify-center rounded-lg border border-input bg-card px-3 text-sm font-medium text-foreground transition-colors hover:bg-accent/50 disabled:pointer-events-none disabled:opacity-50" |
| 446 | 675 | > |
| 447 | | − {previewMutation.status === "pending" ? "Previewing..." : "Preview OpenAPI Document"} |
| 676 | + {previewMutation.status === "pending" ? "Previewing..." : "Preview Spec"} |
| 448 | 677 | </button> |
| 449 | | − {previewMutation.status === "pending" && ( |
| 450 | | − <div className="text-xs text-muted-foreground"> |
| 451 | | − Inferring from spec... |
| 452 | | − </div> |
| 453 | | − )} |
| 454 | 678 | {preview && ( |
| 455 | 679 | <div className="text-xs text-muted-foreground"> |
| 456 | 680 | {preview.operationCount} operations |
| @@ -459,35 +683,31 @@ function OpenApiSourceForm(props: { · 2 unmodified lines |
| 459 | 683 | )} |
| 460 | 684 | </div> |
| 461 | 685 | {preview && ( |
| 462 | | − <div className="mt-4 rounded-lg border border-border/70 bg-background/60 p-4 text-sm"> |
| 463 | | − <div className="grid gap-2"> |
| 464 | | − <div> |
| 465 | | − <span className="font-medium text-foreground">Title:</span>{" "} |
| 466 | | − <span className="text-muted-foreground">{preview.title ?? "Unknown"}</span> |
| 467 | | − </div> |
| 468 | | − <div> |
| 469 | | − <span className="font-medium text-foreground">Version:</span>{" "} |
| 470 | | − <span className="text-muted-foreground">{preview.version ?? "Unknown"}</span> |
| 471 | | − </div> |
| 472 | | − <div> |
| 473 | | − <span className="font-medium text-foreground">Base URL:</span>{" "} |
| 474 | | − <span className="text-muted-foreground">{preview.baseUrl ?? "Not declared"}</span> |
| 475 | | − </div> |
| 476 | | − <div> |
| 477 | | − <span className="font-medium text-foreground">Namespace:</span>{" "} |
| 478 | | − <span className="text-muted-foreground">{preview.namespace ?? "Not inferred"}</span> |
| 479 | | − </div> |
| 480 | | − <div> |
| 481 | | − <span className="font-medium text-foreground">Auth:</span>{" "} |
| 482 | | − <span className="text-muted-foreground"> |
| 483 | | − {preview.securitySchemes.length > 0 |
| 484 | | − ? preview.securitySchemes |
| 485 | | − .map((scheme) => `${scheme.name} (${previewSecuritySchemeLabel(scheme)})`) |
| 486 | | − .join(", ") |
| 487 | | − : "No declared auth schemes"} |
| 488 | | − </span> |
| 489 | | − </div> |
| 490 | | − </div> |
| 686 | + <div className="mt-3 rounded-lg border border-border bg-muted/30 px-4 py-3 text-sm"> |
| 687 | + <dl className="grid grid-cols-[auto_1fr] gap-x-4 gap-y-1.5 text-xs"> |
| 688 | + <dt className="font-medium text-foreground">Title</dt> |
| 689 | + <dd className="text-muted-foreground">{preview.title ?? "—"}</dd> |
| 690 | + <dt className="font-medium text-foreground">Version</dt> |
| 691 | + <dd className="text-muted-foreground">{preview.version ?? "—"}</dd> |
| 692 | + <dt className="font-medium text-foreground">Base URL</dt> |
| 693 | + <dd className="font-mono text-muted-foreground">{preview.baseUrl ?? "—"}</dd> |
| 694 | + {preview.namespace && ( |
| 695 | + <> |
| 696 | + <dt className="font-medium text-foreground">Namespace</dt> |
| 697 | + <dd className="text-muted-foreground">{preview.namespace}</dd> |
| 698 | + </> |
| 699 | + )} |
| 700 | + {preview.securitySchemes.length > 0 && ( |
| 701 | + <> |
| 702 | + <dt className="font-medium text-foreground">Auth</dt> |
| 703 | + <dd className="text-muted-foreground"> |
| 704 | + {preview.securitySchemes |
| 705 | + .map((scheme) => `${scheme.name} (${previewSecuritySchemeLabel(scheme)})`) |
| 706 | + .join(", ")} |
| 707 | + </dd> |
| 708 | + </> |
| 709 | + )} |
| 710 | + </dl> |
| 491 | 711 | {preview.warnings.length > 0 && ( |
| 492 | 712 | <div className="mt-3 rounded-md border border-amber-300/40 bg-amber-100/20 px-3 py-2 text-xs text-amber-800"> |
| 493 | 713 | {preview.warnings.join(" ")} |
| @@ -497,38 +717,31 @@ function OpenApiSourceForm(props: { · 3 unmodified lines |
| 497 | 717 | )} |
| 498 | 718 | </Section> |
| 499 | 719 | |
| 500 | | − <Section title={props.mode === "create" ? "Submit" : "Save"}> |
| 501 | | − {error && ( |
| 502 | | − <div className="mb-3 rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-2.5 text-[13px] text-destructive"> |
| 503 | | − {error} |
| 504 | | − </div> |
| 505 | | − )} |
| 506 | | − <div className="flex items-center gap-3"> |
| 507 | | − <button |
| 508 | | − type="button" |
| 509 | | − onClick={() => { |
| 510 | | − void handleSubmit(); |
| 511 | | − }} |
| 512 | | − disabled={submitMutation.status === "pending"} |
| 513 | | − className="inline-flex h-7 items-center justify-center rounded-md bg-primary px-2.5 text-xs font-medium text-primary-foreground disabled:pointer-events-none disabled:opacity-50" |
| 514 | | − > |
| 515 | | − {submitMutation.status === "pending" ? props.busyLabel : props.submitLabel} |
| 516 | | − </button> |
| 517 | | − <div className="text-xs text-muted-foreground"> |
| 518 | | − {props.mode === "create" |
| 519 | | − ? "Creates a real source and immediately imports its tools." |
| 520 | | − : "Updates plugin-owned config and refreshes the imported model."} |
| 521 | | − </div> |
| 720 | + {error && ( |
| 721 | + <div className="rounded-lg border border-destructive/30 bg-destructive/8 px-4 py-2.5 text-sm text-destructive"> |
| 722 | + {error} |
| 522 | 723 | </div> |
| 523 | | − </Section> |
| 724 | + )} |
| 725 | + |
| 726 | + <div className="flex items-center justify-end"> |
| 727 | + <button |
| 728 | + type="button" |
| 729 | + onClick={() => { |
| 730 | + void handleSubmit(); |
| 731 | + }} |
| 732 | + disabled={submitMutation.status === "pending"} |
| 733 | + className="inline-flex h-9 items-center justify-center rounded-lg bg-primary px-4 text-sm font-medium text-primary-foreground transition-opacity hover:opacity-90 disabled:pointer-events-none disabled:opacity-50" |
| 734 | + > |
| 735 | + {submitMutation.status === "pending" ? props.busyLabel : props.submitLabel} |
| 736 | + </button> |
| 737 | + </div> |
| 524 | 738 | </div> |
| 525 | 739 | ); |
| 526 | 740 | } |
| 527 | 741 | |
| 528 | | −function OpenApiAddSourcePage(props: { |
| 529 | | − initialValue: OpenApiConnectInput; |
| 530 | | −}) { |
| 742 | +function OpenApiAddSourcePage() { |
| 531 | 743 | const navigation = useSourcePluginNavigation(); |
| 744 | + const initialValue = openApiInputFromSearch(useSourcePluginSearch()); |
| 532 | 745 | const openApiHttpClient = getOpenApiHttpClient(); |
| 533 | 746 | const createSource = useAtomSet( |
| 534 | 747 | openApiHttpClient.mutation("openapi", "createSource"), |
| @@ -539,7 +752,7 @@ function OpenApiAddSourcePage(props: { · 4 unmodified lines |
| 539 | 752 | return ( |
| 540 | 753 | <OpenApiSourceForm |
| 541 | 754 | mode="create" |
| 542 | | − initialValue={props.initialValue} |
| 755 | + initialValue={initialValue} |
| 543 | 756 | submitLabel="Create Source" |
| 544 | 757 | busyLabel="Creating..." |
| 545 | 758 | onSubmit={async (payload) => { |
| @@ -587,17 +800,17 @@ function OpenApiEditSourcePage(props: { · 41 unmodified lines |
| 587 | 800 | if (!Result.isSuccess(configResult)) { |
| 588 | 801 | if (Result.isFailure(configResult)) { |
| 589 | 802 | return ( |
| 590 | | − <Section title="OpenAPI Plugin Editor"> |
| 803 | + <Section title="Edit Source"> |
| 591 | 804 | <div className="rounded-lg border border-destructive/30 bg-destructive/8 p-4 text-sm text-destructive"> |
| 592 | | − Failed loading plugin config. |
| 805 | + Failed loading source configuration. |
| 593 | 806 | </div> |
| 594 | 807 | </Section> |
| 595 | 808 | ); |
| 596 | 809 | } |
| 597 | 810 | |
| 598 | 811 | return ( |
| 599 | | − <Section title="OpenAPI Plugin Editor"> |
| 600 | | − <div className="text-sm text-muted-foreground">Loading plugin config...</div> |
| 812 | + <Section title="Edit Source"> |
| 813 | + <div className="text-sm text-muted-foreground">Loading configuration...</div> |
| 601 | 814 | </Section> |
| 602 | 815 | ); |
| 603 | 816 | } |
| @@ -692,6 +905,7 @@ function OpenApiSourceDetailPage(props: { · 88 unmodified lines |
| 692 | 905 | limit: 20, |
| 693 | 906 | }); |
| 694 | 907 | const toolDetail = useSourceToolDetail(props.source.id, selectedToolPath); |
| 908 | + const [confirmDelete, setConfirmDelete] = useState(false); |
| 695 | 909 | |
| 696 | 910 | const setRouteSearch = (next: { |
| 697 | 911 | tab?: "model" | "discover"; |
| @@ -781,23 +995,46 @@ function OpenApiSourceDetailPage(props: { · 83 unmodified lines |
| 781 | 995 | <IconPencil className="size-3" /> |
| 782 | 996 | Edit |
| 783 | 997 | </button> |
| 784 | | − <button |
| 785 | | − type="button" |
| 786 | | − onClick={() => { |
| 787 | | − const confirmed = window.confirm(`Delete OpenAPI source "${props.source.name}"?`); |
| 788 | | − if (!confirmed) return; |
| 789 | | − |
| 790 | | − void removeMutation.mutateAsync(props.source.id).then(() => { |
| 791 | | − startTransition(() => { |
| 792 | | − void navigation.home(); |
| 793 | | − }); |
| 794 | | − }); |
| 795 | | − }} |
| 796 | | − disabled={removeMutation.status === "pending"} |
| 797 | | − className="inline-flex items-center rounded-md border border-destructive/25 bg-destructive/5 px-2.5 py-1 text-[12px] font-medium text-destructive transition-colors hover:bg-destructive/10 disabled:pointer-events-none disabled:opacity-50" |
| 798 | | − > |
| 799 | | − {removeMutation.status === "pending" ? "Deleting..." : "Delete"} |
| 800 | | − </button> |
| 998 | + {confirmDelete ? ( |
| 999 | + <div className="flex items-center gap-2"> |
| 1000 | + <span className="text-[11px] font-medium text-destructive"> |
| 1001 | + Confirm delete? |
| 1002 | + </span> |
| 1003 | + <button |
| 1004 | + type="button" |
| 1005 | + onClick={() => setConfirmDelete(false)} |
| 1006 | + disabled={removeMutation.status === "pending"} |
| 1007 | + className="inline-flex items-center rounded-md border border-border bg-card px-2.5 py-1 text-[12px] font-medium text-muted-foreground transition-colors hover:bg-accent/50 hover:text-foreground disabled:pointer-events-none disabled:opacity-50" |
| 1008 | + > |
| 1009 | + Cancel |
| 1010 | + </button> |
| 1011 | + <button |
| 1012 | + type="button" |
| 1013 | + onClick={() => { |
| 1014 | + void removeMutation.mutateAsync(props.source.id).then(() => { |
| 1015 | + startTransition(() => { |
| 1016 | + void navigation.home(); |
| 1017 | + }); |
| 1018 | + }).finally(() => { |
| 1019 | + setConfirmDelete(false); |
| 1020 | + }); |
| 1021 | + }} |
| 1022 | + disabled={removeMutation.status === "pending"} |
| 1023 | + className="inline-flex items-center rounded-md border border-destructive/25 bg-destructive/5 px-2.5 py-1 text-[12px] font-medium text-destructive transition-colors hover:bg-destructive/10 disabled:pointer-events-none disabled:opacity-50" |
| 1024 | + > |
| 1025 | + {removeMutation.status === "pending" ? "Deleting..." : "Delete"} |
| 1026 | + </button> |
| 1027 | + </div> |
| 1028 | + ) : ( |
| 1029 | + <button |
| 1030 | + type="button" |
| 1031 | + onClick={() => setConfirmDelete(true)} |
| 1032 | + disabled={removeMutation.status === "pending"} |
| 1033 | + className="inline-flex items-center rounded-md border border-destructive/25 bg-destructive/5 px-2.5 py-1 text-[12px] font-medium text-destructive transition-colors hover:bg-destructive/10 disabled:pointer-events-none disabled:opacity-50" |
| 1034 | + > |
| 1035 | + Delete |
| 1036 | + </button> |
| 1037 | + )} |
| 801 | 1038 | </div> |
| 802 | 1039 | </div> |
| 803 | 1040 | |
| @@ -1425,9 +1662,7 @@ const openApiSourceType = defineFrontendSourceType({ · 621 unmodified lines |
| 1425 | 1662 | key: "openapi", |
| 1426 | 1663 | kind: "openapi", |
| 1427 | 1664 | displayName: "OpenAPI", |
| 1428 | | − renderAddPage: () => ( |
| 1429 | | − <OpenApiAddSourcePage initialValue={defaultOpenApiInput()} /> |
| 1430 | | − ), |
| 1665 | + renderAddPage: OpenApiAddSourcePage, |
| 1431 | 1666 | renderEditPage: OpenApiEditSourcePage, |
| 1432 | 1667 | renderDetailPage: OpenApiSourceDetailPage, |
| 1433 | 1668 | }); |