import { useCallback, useEffect, useMemo, useRef, useState } from "react";import { useAtomSet } from "@effect/atom-react";import * as Exit from "effect/Exit";import * as Option from "effect/Option";import * as Schema from "effect/Schema"; import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys";import { useScope, useUserScope } from "@executor-js/react/api/scope-context";import type { SecretPickerSecret } from "@executor-js/react/plugins/secret-picker";import { CreatableSecretPicker } from "@executor-js/react/plugins/secret-header-auth";import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets";import type { ScopeId } from "@executor-js/sdk";import { Badge } from "@executor-js/react/components/badge";import { Button } from "@executor-js/react/components/button";import { CardStack, CardStackContent, CardStackEntryField,} from "@executor-js/react/components/card-stack";import { SourceIdentityFields, slugifyNamespace, useSourceIdentity,} from "@executor-js/react/plugins/source-identity";import { oauthCallbackUrl, oauthConnectionId, useOAuthPopupFlow,} from "@executor-js/react/plugins/oauth-sign-in";import { Collapsible, CollapsibleContent, CollapsibleTrigger,} from "@executor-js/react/components/collapsible";import { Field, FieldContent, FieldDescription, FieldGroup, FieldLabel, FieldLegend, FieldSet, FieldTitle,} from "@executor-js/react/components/field";import { FilterTabs } from "@executor-js/react/components/filter-tabs";import { FloatActions } from "@executor-js/react/components/float-actions";import { Input } from "@executor-js/react/components/input";import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group";import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner";import { addGoogleDiscoverySourceOptimistic, probeGoogleDiscovery } from "./atoms";import { GOOGLE_DISCOVERY_OAUTH_POPUP_NAME, googleDiscoveryOAuthStrategy } from "./oauth";import { googleDiscoveryPresets, type GoogleDiscoveryPreset } from "../sdk/presets"; const ErrorMessage = Schema.Struct({ message: Schema.String });const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), { onNone: () => fallback, onSome: ({ message }) => message, }); type GoogleAuthKind = "none" | "oauth2"; // ---------------------------------------------------------------------------// Client secret field with inline creation// --------------------------------------------------------------------------- function SecretBackedField(props: { label: string; help?: string; suggestedSecretId: string; secretId: string | null; onSelect: (secretId: string | null) => void; secretList: readonly SecretPickerSecret[]; placeholder: string; targetScope: ScopeId; clearable?: boolean;}) { const { label, help, secretId, onSelect, secretList, placeholder, clearable = true } = props; return ( <div className="space-y-2"> <div className="space-y-1"> <FieldLabel className="text-[11px]">{label}</FieldLabel> {help && <p className="text-xs text-muted-foreground">{help}</p>} </div> <div className="flex items-center gap-2"> <div className="flex-1 min-w-0"> <CreatableSecretPicker value={secretId} onSelect={(id) => onSelect(id)} secrets={secretList} placeholder={placeholder} suggestedId={props.suggestedSecretId} secretLabel={label} targetScope={props.targetScope} /> </div> {clearable && secretId && ( <Button variant="outline" onClick={() => onSelect(null)}> Clear </Button> )} </div> </div> );} type GoogleDiscoveryTemplate = GoogleDiscoveryPreset & { readonly discoveryUrl: string; readonly service: string; readonly version: string;}; const GOOGLE_G_ICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg"; function parseGoogleDiscoveryPreset(preset: GoogleDiscoveryPreset): GoogleDiscoveryTemplate { // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor normalizes user-provided preset URLs try { const url = new URL(preset.url); const parts = url.pathname.split("/").filter(Boolean); const apisIndex = parts.indexOf("apis"); const service = apisIndex >= 0 ? parts[apisIndex + 1] : undefined; const version = apisIndex >= 0 ? parts[apisIndex + 2] : (url.searchParams.get("version") ?? undefined); return { ...preset, discoveryUrl: preset.url, service: service ?? url.hostname.replace(/\.googleapis\.com$/, ""), version: version ?? "", }; } catch { return { ...preset, discoveryUrl: preset.url, service: preset.id, version: "" }; }} const GOOGLE_DISCOVERY_TEMPLATES = googleDiscoveryPresets.map(parseGoogleDiscoveryPreset); const iconForService = (service: string): string | undefined => GOOGLE_DISCOVERY_TEMPLATES.find((template) => template.service === service)?.icon; function GoogleServiceIcon(props: { readonly icon?: string; readonly service?: string; readonly className?: string;}) { const { icon, service, className = "size-11" } = props; const src = icon ?? (service ? iconForService(service) : undefined) ?? GOOGLE_G_ICON; return ( <img alt="" aria-hidden="true" className={`${className} shrink-0 object-contain`} decoding="async" draggable={false} loading="lazy" src={src} /> );} type ProbeOperation = { toolPath: string; method: string; pathTemplate: string; description: string | null;}; type ProbeResult = { name: string; title: string | null; service: string; version: string; toolCount: number; scopes: readonly string[]; operations: readonly ProbeOperation[];}; type OAuthAuth = { kind: "oauth2"; connectionId: string; clientIdSecretId: string; clientSecretSecretId: string | null; scopes: string[];}; export default function AddGoogleDiscoverySource(props: { readonly onComplete: () => void; readonly onCancel: () => void; readonly initialUrl?: string; readonly initialPreset?: string;}) { const defaultTemplate = GOOGLE_DISCOVERY_TEMPLATES.find((template) => template.id === props.initialPreset) ?? GOOGLE_DISCOVERY_TEMPLATES.find((template) => template.id === "google-sheets") ?? GOOGLE_DISCOVERY_TEMPLATES[0]!; const [discoveryUrl, setDiscoveryUrl] = useState( props.initialUrl ?? defaultTemplate.discoveryUrl, ); const [selectedTemplateId, setSelectedTemplateId] = useState( props.initialUrl ? "" : defaultTemplate.id, ); const selectedTemplate = GOOGLE_DISCOVERY_TEMPLATES.find((template) => template.id === selectedTemplateId) ?? null; const [authKind, setAuthKind] = useState<GoogleAuthKind>("oauth2"); const [clientIdSecretId, setClientIdSecretId] = useState<string | null>(null); const [clientSecretSecretId, setClientSecretSecretId] = useState<string | null>(null); const [probe, setProbe] = useState<ProbeResult | null>(null); const identity = useSourceIdentity({ fallbackName: probe?.name ?? selectedTemplate?.name ?? "", }); const [oauthAuth, setOauthAuth] = useState<OAuthAuth | null>(null); const [loadingProbe, setLoadingProbe] = useState(false); const [adding, setAdding] = useState(false); const [error, setError] = useState<string | null>(null); const [showScopes, setShowScopes] = useState(false); const resolvedNamespace = slugifyNamespace(identity.namespace) || slugifyNamespace(probe?.name ?? selectedTemplate?.name ?? "") || "google"; const scopeId = useScope(); const userScopeId = useUserScope(); const doProbe = useAtomSet(probeGoogleDiscovery, { mode: "promiseExit" }); const doAdd = useAtomSet(addGoogleDiscoverySourceOptimistic(scopeId), { mode: "promiseExit", }); const secretList = useSecretPickerSecrets(); const oauth = useOAuthPopupFlow({ popupName: GOOGLE_DISCOVERY_OAUTH_POPUP_NAME, popupBlockedMessage: "OAuth popup was blocked", popupClosedMessage: "OAuth cancelled: popup was closed before completing the flow.", startErrorMessage: "Failed to start OAuth", }); const canUseOAuth = useMemo(() => (probe?.scopes.length ?? 0) > 0, [probe]); const applyTemplate = useCallback( (template: GoogleDiscoveryTemplate) => { setSelectedTemplateId(template.id); setDiscoveryUrl(template.discoveryUrl); identity.reset(); setClientSecretSecretId(null); setProbe(null); setOauthAuth(null); setError(null); setShowScopes(false); setAuthKind("oauth2"); }, [identity], ); const handleProbe = useCallback(async () => { setLoadingProbe(true); setError(null); setOauthAuth(null); setShowScopes(false); const exit = await doProbe({ params: { scopeId }, payload: { discoveryUrl: discoveryUrl.trim() }, }); if (Exit.isFailure(exit)) { setProbe(null); setLoadingProbe(false); setError(errorMessageFromExit(exit, "Failed to inspect discovery document")); return; } const result = exit.value; setProbe({ ...result, scopes: [...result.scopes], operations: [...result.operations], }); if (result.scopes.length === 0) { setAuthKind("none"); } setLoadingProbe(false); }, [discoveryUrl, doProbe, scopeId]); // Keep the latest handleProbe in a ref so the debounced effect can call it // without depending on its identity (which changes every render). const handleProbeRef = useRef(handleProbe); handleProbeRef.current = handleProbe; // Auto-probe whenever the discovery URL changes (debounced). Clearing the // previous probe in the onChange handler resets the preview so a new run // will be triggered. useEffect(() => { const trimmed = discoveryUrl.trim(); if (!trimmed) return; if (probe) return; const handle = setTimeout(() => { handleProbeRef.current(); }, 400); return () => clearTimeout(handle); }, [discoveryUrl, probe]); const handleStartOAuth = useCallback(async () => { if (!probe || !clientIdSecretId) return; setError(null); const scopes = [...probe.scopes]; await oauth.start({ payload: { endpoint: discoveryUrl.trim(), redirectUrl: oauthCallbackUrl(), connectionId: oauthConnectionId({ pluginId: "google-discovery", namespace: resolvedNamespace, }), tokenScope: userScopeId, identityLabel: `${identity.name.trim() || probe.title || probe.name} OAuth`, strategy: googleDiscoveryOAuthStrategy({ clientIdSecretId, clientSecretSecretId, scopes, }), pluginId: "google-discovery", }, onSuccess: (result) => { setOauthAuth({ kind: "oauth2", connectionId: result.connectionId, clientIdSecretId, clientSecretSecretId, scopes, }); setError(null); }, onError: setError, }); }, [ probe, discoveryUrl, identity.name, clientIdSecretId, clientSecretSecretId, resolvedNamespace, oauth, userScopeId, ]); const handleCancelOAuth = useCallback(() => { oauth.cancel(); }, [oauth]); const handleAdd = useCallback(async () => { if (!probe) return; setAdding(true); setError(null); const displayName = identity.name.trim() || probe.name; const namespace = resolvedNamespace; const exit = await doAdd({ params: { scopeId }, payload: { name: displayName, discoveryUrl: discoveryUrl.trim(), namespace, auth: authKind === "oauth2" && oauthAuth ? { kind: "oauth2" as const, connectionId: oauthAuth.connectionId, clientIdSecretId: oauthAuth.clientIdSecretId, clientSecretSecretId: oauthAuth.clientSecretSecretId, scopes: oauthAuth.scopes, } : { kind: "none" as const }, }, reactivityKeys: [...sourceWriteKeys], }); if (Exit.isFailure(exit)) { setError(errorMessageFromExit(exit, "Failed to add source")); setAdding(false); return; } props.onComplete(); }, [ probe, doAdd, identity, discoveryUrl, authKind, oauthAuth, props, scopeId, resolvedNamespace, ]); const addDisabled = !probe || adding || (authKind === "oauth2" && (!canUseOAuth || oauthAuth === null)); return ( <div className="flex flex-1 flex-col gap-6"> <div> <h1 className="text-xl font-semibold text-foreground">Add Google Discovery Source</h1> <p className="mt-1 text-[13px] text-muted-foreground"> Connect a Google API from its Discovery document and register its methods as tools. </p> </div> <FieldGroup> <FieldSet> <FieldLegend variant="label">Presets</FieldLegend> <FieldDescription>Select a Google API to prefill the source.</FieldDescription> <RadioGroup value={selectedTemplateId} onValueChange={(value) => { const template = GOOGLE_DISCOVERY_TEMPLATES.find((t) => t.id === value); if (template) applyTemplate(template); }} className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3" > {GOOGLE_DISCOVERY_TEMPLATES.map((template) => { const inputId = `google-discovery-preset-${template.id}`; return ( <FieldLabel key={template.id} htmlFor={inputId}> <Field orientation="horizontal"> <GoogleServiceIcon icon={template.icon} service={template.service} className="size-8" /> <FieldContent> <FieldTitle>{template.name}</FieldTitle> <FieldDescription className="line-clamp-2"> {template.summary} </FieldDescription> </FieldContent> <RadioGroupItem id={inputId} value={template.id} /> </Field> </FieldLabel> ); })} </RadioGroup> </FieldSet> </FieldGroup> <CardStack> <CardStackContent className="border-t-0"> <CardStackEntryField label="Discovery URL"> <div className="relative"> <Input value={discoveryUrl} onChange={(e) => { setSelectedTemplateId(""); setDiscoveryUrl((e.target as HTMLInputElement).value); setProbe(null); setOauthAuth(null); setError(null); }} placeholder="https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest" className="w-full pr-9 font-mono text-sm" /> {loadingProbe && ( <div className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2"> <IOSSpinner className="size-4" /> </div> )} </div> </CardStackEntryField> </CardStackContent> </CardStack> <SourceIdentityFields identity={identity} namePlaceholder="Google Sheets" namespacePlaceholder="google_sheets" /> {probe && ( <section className="space-y-3 rounded-xl border border-border bg-card px-4 py-4"> <div className="flex items-center justify-between gap-3"> <div className="flex items-center gap-3"> <div className="flex size-9 shrink-0 items-center justify-center rounded-xl border border-border/60 bg-background/80 shadow-xs"> <GoogleServiceIcon icon={selectedTemplate?.icon} service={selectedTemplate?.service ?? probe.service} className="size-5" /> </div> <div> <p className="text-sm font-semibold text-foreground">{probe.title ?? probe.name}</p> <p className="mt-1 text-xs text-muted-foreground"> {probe.service} · {probe.version} </p> </div> </div> <div className="flex gap-2"> <Badge variant="secondary">{probe.toolCount} tools</Badge> <Badge variant="outline">{probe.scopes.length} scopes</Badge> </div> </div> </section> )} <section className="space-y-2.5"> <div className="flex items-center justify-between gap-3"> <FieldLabel>Authentication</FieldLabel>Lines 1–500 of 623. Load more
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAtomSet } from "@effect/atom-react"; import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { useScope, useUserScope } from "@executor-js/react/api/scope-context"; import type { SecretPickerSecret } from "@executor-js/react/plugins/secret-picker"; import { CreatableSecretPicker } from "@executor-js/react/plugins/secret-header-auth"; import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets"; import type { ScopeId } from "@executor-js/sdk"; import { Badge } from "@executor-js/react/components/badge"; import { Button } from "@executor-js/react/components/button"; import { CardStack, CardStackContent, CardStackEntryField, } from "@executor-js/react/components/card-stack"; import { SourceIdentityFields, slugifyNamespace, useSourceIdentity, } from "@executor-js/react/plugins/source-identity"; import { oauthCallbackUrl, oauthConnectionId, useOAuthPopupFlow, } from "@executor-js/react/plugins/oauth-sign-in"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@executor-js/react/components/collapsible"; import { Field, FieldContent, FieldDescription, FieldGroup, FieldLabel, FieldLegend, FieldSet, FieldTitle, } from "@executor-js/react/components/field"; import { FilterTabs } from "@executor-js/react/components/filter-tabs"; import { FloatActions } from "@executor-js/react/components/float-actions"; import { Input } from "@executor-js/react/components/input"; import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group"; import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; import { addGoogleDiscoverySourceOptimistic, probeGoogleDiscovery } from "./atoms"; import { GOOGLE_DISCOVERY_OAUTH_POPUP_NAME, googleDiscoveryOAuthStrategy } from "./oauth"; import { googleDiscoveryPresets, type GoogleDiscoveryPreset } from "../sdk/presets"; const ErrorMessage = Schema.Struct({ message: Schema.String }); const decodeErrorMessage = Schema.decodeUnknownOption(ErrorMessage); const errorMessageFromExit = (exit: Exit.Exit<unknown, unknown>, fallback: string): string => Option.match(Option.flatMap(Exit.findErrorOption(exit), decodeErrorMessage), { onNone: () => fallback, onSome: ({ message }) => message, }); type GoogleAuthKind = "none" | "oauth2"; // --------------------------------------------------------------------------- // Client secret field with inline creation // --------------------------------------------------------------------------- function SecretBackedField(props: { label: string; help?: string; suggestedSecretId: string; secretId: string | null; onSelect: (secretId: string | null) => void; secretList: readonly SecretPickerSecret[]; placeholder: string; targetScope: ScopeId; clearable?: boolean; }) { const { label, help, secretId, onSelect, secretList, placeholder, clearable = true } = props; return ( <div className="space-y-2"> <div className="space-y-1"> <FieldLabel className="text-[11px]">{label}</FieldLabel> {help && <p className="text-xs text-muted-foreground">{help}</p>} </div> <div className="flex items-center gap-2"> <div className="flex-1 min-w-0"> <CreatableSecretPicker value={secretId} onSelect={(id) => onSelect(id)} secrets={secretList} placeholder={placeholder} suggestedId={props.suggestedSecretId} secretLabel={label} targetScope={props.targetScope} /> </div> {clearable && secretId && ( <Button variant="outline" onClick={() => onSelect(null)}> Clear </Button> )} </div> </div> ); } type GoogleDiscoveryTemplate = GoogleDiscoveryPreset & { readonly discoveryUrl: string; readonly service: string; readonly version: string; }; const GOOGLE_G_ICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg"; function parseGoogleDiscoveryPreset(preset: GoogleDiscoveryPreset): GoogleDiscoveryTemplate { // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor normalizes user-provided preset URLs try { const url = new URL(preset.url); const parts = url.pathname.split("/").filter(Boolean); const apisIndex = parts.indexOf("apis"); const service = apisIndex >= 0 ? parts[apisIndex + 1] : undefined; const version = apisIndex >= 0 ? parts[apisIndex + 2] : (url.searchParams.get("version") ?? undefined); return { ...preset, discoveryUrl: preset.url, service: service ?? url.hostname.replace(/\.googleapis\.com$/, ""), version: version ?? "", }; } catch { return { ...preset, discoveryUrl: preset.url, service: preset.id, version: "" }; } } const GOOGLE_DISCOVERY_TEMPLATES = googleDiscoveryPresets.map(parseGoogleDiscoveryPreset); const iconForService = (service: string): string | undefined => GOOGLE_DISCOVERY_TEMPLATES.find((template) => template.service === service)?.icon; function GoogleServiceIcon(props: { readonly icon?: string; readonly service?: string; readonly className?: string; }) { const { icon, service, className = "size-11" } = props; const src = icon ?? (service ? iconForService(service) : undefined) ?? GOOGLE_G_ICON; return ( <img alt="" aria-hidden="true" className={`${className} shrink-0 object-contain`} decoding="async" draggable={false} loading="lazy" src={src} /> ); } type ProbeOperation = { toolPath: string; method: string; pathTemplate: string; description: string | null; }; type ProbeResult = { name: string; title: string | null; service: string; version: string; toolCount: number; scopes: readonly string[]; operations: readonly ProbeOperation[]; }; type OAuthAuth = { kind: "oauth2"; connectionId: string; clientIdSecretId: string; clientSecretSecretId: string | null; scopes: string[]; }; export default function AddGoogleDiscoverySource(props: { readonly onComplete: () => void; readonly onCancel: () => void; readonly initialUrl?: string; readonly initialPreset?: string; }) { const defaultTemplate = GOOGLE_DISCOVERY_TEMPLATES.find((template) => template.id === props.initialPreset) ?? GOOGLE_DISCOVERY_TEMPLATES.find((template) => template.id === "google-sheets") ?? GOOGLE_DISCOVERY_TEMPLATES[0]!; const [discoveryUrl, setDiscoveryUrl] = useState( props.initialUrl ?? defaultTemplate.discoveryUrl, ); const [selectedTemplateId, setSelectedTemplateId] = useState( props.initialUrl ? "" : defaultTemplate.id, ); const selectedTemplate = GOOGLE_DISCOVERY_TEMPLATES.find((template) => template.id === selectedTemplateId) ?? null; const [authKind, setAuthKind] = useState<GoogleAuthKind>("oauth2"); const [clientIdSecretId, setClientIdSecretId] = useState<string | null>(null); const [clientSecretSecretId, setClientSecretSecretId] = useState<string | null>(null); const [probe, setProbe] = useState<ProbeResult | null>(null); const identity = useSourceIdentity({ fallbackName: probe?.name ?? selectedTemplate?.name ?? "", }); const [oauthAuth, setOauthAuth] = useState<OAuthAuth | null>(null); const [loadingProbe, setLoadingProbe] = useState(false); const [adding, setAdding] = useState(false); const [error, setError] = useState<string | null>(null); const [showScopes, setShowScopes] = useState(false); const resolvedNamespace = slugifyNamespace(identity.namespace) || slugifyNamespace(probe?.name ?? selectedTemplate?.name ?? "") || "google"; const scopeId = useScope(); const userScopeId = useUserScope(); const doProbe = useAtomSet(probeGoogleDiscovery, { mode: "promiseExit" }); const doAdd = useAtomSet(addGoogleDiscoverySourceOptimistic(scopeId), { mode: "promiseExit", }); const secretList = useSecretPickerSecrets(); const oauth = useOAuthPopupFlow({ popupName: GOOGLE_DISCOVERY_OAUTH_POPUP_NAME, popupBlockedMessage: "OAuth popup was blocked", popupClosedMessage: "OAuth cancelled: popup was closed before completing the flow.", startErrorMessage: "Failed to start OAuth", }); const canUseOAuth = useMemo(() => (probe?.scopes.length ?? 0) > 0, [probe]); const applyTemplate = useCallback( (template: GoogleDiscoveryTemplate) => { setSelectedTemplateId(template.id); setDiscoveryUrl(template.discoveryUrl); identity.reset(); setClientSecretSecretId(null); setProbe(null); setOauthAuth(null); setError(null); setShowScopes(false); setAuthKind("oauth2"); }, [identity], ); const handleProbe = useCallback(async () => { setLoadingProbe(true); setError(null); setOauthAuth(null); setShowScopes(false); const exit = await doProbe({ params: { scopeId }, payload: { discoveryUrl: discoveryUrl.trim() }, }); if (Exit.isFailure(exit)) { setProbe(null); setLoadingProbe(false); setError(errorMessageFromExit(exit, "Failed to inspect discovery document")); return; } const result = exit.value; setProbe({ ...result, scopes: [...result.scopes], operations: [...result.operations], }); if (result.scopes.length === 0) { setAuthKind("none"); } setLoadingProbe(false); }, [discoveryUrl, doProbe, scopeId]); // Keep the latest handleProbe in a ref so the debounced effect can call it // without depending on its identity (which changes every render). const handleProbeRef = useRef(handleProbe); handleProbeRef.current = handleProbe; // Auto-probe whenever the discovery URL changes (debounced). Clearing the // previous probe in the onChange handler resets the preview so a new run // will be triggered. useEffect(() => { const trimmed = discoveryUrl.trim(); if (!trimmed) return; if (probe) return; const handle = setTimeout(() => { handleProbeRef.current(); }, 400); return () => clearTimeout(handle); }, [discoveryUrl, probe]); const handleStartOAuth = useCallback(async () => { if (!probe || !clientIdSecretId) return; setError(null); const scopes = [...probe.scopes]; await oauth.start({ payload: { endpoint: discoveryUrl.trim(), redirectUrl: oauthCallbackUrl(), connectionId: oauthConnectionId({ pluginId: "google-discovery", namespace: resolvedNamespace, }), tokenScope: userScopeId, identityLabel: `${identity.name.trim() || probe.title || probe.name} OAuth`, strategy: googleDiscoveryOAuthStrategy({ clientIdSecretId, clientSecretSecretId, scopes, }), pluginId: "google-discovery", }, onSuccess: (result) => { setOauthAuth({ kind: "oauth2", connectionId: result.connectionId, clientIdSecretId, clientSecretSecretId, scopes, }); setError(null); }, onError: setError, }); }, [ probe, discoveryUrl, identity.name, clientIdSecretId, clientSecretSecretId, resolvedNamespace, oauth, userScopeId, ]); const handleCancelOAuth = useCallback(() => { oauth.cancel(); }, [oauth]); const handleAdd = useCallback(async () => { if (!probe) return; setAdding(true); setError(null); const displayName = identity.name.trim() || probe.name; const namespace = resolvedNamespace; const exit = await doAdd({ params: { scopeId }, payload: { name: displayName, discoveryUrl: discoveryUrl.trim(), namespace, auth: authKind === "oauth2" && oauthAuth ? { kind: "oauth2" as const, connectionId: oauthAuth.connectionId, clientIdSecretId: oauthAuth.clientIdSecretId, clientSecretSecretId: oauthAuth.clientSecretSecretId, scopes: oauthAuth.scopes, } : { kind: "none" as const }, }, reactivityKeys: [...sourceWriteKeys], }); if (Exit.isFailure(exit)) { setError(errorMessageFromExit(exit, "Failed to add source")); setAdding(false); return; } props.onComplete(); }, [ probe, doAdd, identity, discoveryUrl, authKind, oauthAuth, props, scopeId, resolvedNamespace, ]); const addDisabled = !probe || adding || (authKind === "oauth2" && (!canUseOAuth || oauthAuth === null)); return ( <div className="flex flex-1 flex-col gap-6"> <div> <h1 className="text-xl font-semibold text-foreground">Add Google Discovery Source</h1> <p className="mt-1 text-[13px] text-muted-foreground"> Connect a Google API from its Discovery document and register its methods as tools. </p> </div> <FieldGroup> <FieldSet> <FieldLegend variant="label">Presets</FieldLegend> <FieldDescription>Select a Google API to prefill the source.</FieldDescription> <RadioGroup value={selectedTemplateId} onValueChange={(value) => { const template = GOOGLE_DISCOVERY_TEMPLATES.find((t) => t.id === value); if (template) applyTemplate(template); }} className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3" > {GOOGLE_DISCOVERY_TEMPLATES.map((template) => { const inputId = `google-discovery-preset-${template.id}`; return ( <FieldLabel key={template.id} htmlFor={inputId}> <Field orientation="horizontal"> <GoogleServiceIcon icon={template.icon} service={template.service} className="size-8" /> <FieldContent> <FieldTitle>{template.name}</FieldTitle> <FieldDescription className="line-clamp-2"> {template.summary} </FieldDescription> </FieldContent> <RadioGroupItem id={inputId} value={template.id} /> </Field> </FieldLabel> ); })} </RadioGroup> </FieldSet> </FieldGroup> <CardStack> <CardStackContent className="border-t-0"> <CardStackEntryField label="Discovery URL"> <div className="relative"> <Input value={discoveryUrl} onChange={(e) => { setSelectedTemplateId(""); setDiscoveryUrl((e.target as HTMLInputElement).value); setProbe(null); setOauthAuth(null); setError(null); }} placeholder="https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest" className="w-full pr-9 font-mono text-sm" /> {loadingProbe && ( <div className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2"> <IOSSpinner className="size-4" /> </div> )} </div> </CardStackEntryField> </CardStackContent> </CardStack> <SourceIdentityFields identity={identity} namePlaceholder="Google Sheets" namespacePlaceholder="google_sheets" /> {probe && ( <section className="space-y-3 rounded-xl border border-border bg-card px-4 py-4"> <div className="flex items-center justify-between gap-3"> <div className="flex items-center gap-3"> <div className="flex size-9 shrink-0 items-center justify-center rounded-xl border border-border/60 bg-background/80 shadow-xs"> <GoogleServiceIcon icon={selectedTemplate?.icon} service={selectedTemplate?.service ?? probe.service} className="size-5" /> </div> <div> <p className="text-sm font-semibold text-foreground">{probe.title ?? probe.name}</p> <p className="mt-1 text-xs text-muted-foreground"> {probe.service} · {probe.version} </p> </div> </div> <div className="flex gap-2"> <Badge variant="secondary">{probe.toolCount} tools</Badge> <Badge variant="outline">{probe.scopes.length} scopes</Badge> </div> </div> </section> )} <section className="space-y-2.5"> <div className="flex items-center justify-between gap-3"> <FieldLabel>Authentication</FieldLabel>