diff --git a/packages/plugins/google-discovery/src/api/group.ts b/packages/plugins/google-discovery/src/api/group.ts index ae49991212..b1bdfec5ca 100644 --- a/packages/plugins/google-discovery/src/api/group.ts +++ b/packages/plugins/google-discovery/src/api/group.ts @@ -15,7 +15,7 @@ const AuthPayload = Schema.Union( }), Schema.Struct({ kind: Schema.Literal("oauth2"), - clientId: Schema.String, + clientIdSecretId: Schema.String, clientSecretSecretId: Schema.NullOr(Schema.String), accessTokenSecretId: Schema.String, refreshTokenSecretId: Schema.NullOr(Schema.String), @@ -30,6 +30,13 @@ const ProbePayload = Schema.Struct({ discoveryUrl: Schema.String, }); +const ProbeOperation = Schema.Struct({ + toolPath: Schema.String, + method: Schema.String, + pathTemplate: Schema.String, + description: Schema.NullOr(Schema.String), +}); + const ProbeResponse = Schema.Struct({ name: Schema.String, title: Schema.NullOr(Schema.String), @@ -37,6 +44,7 @@ const ProbeResponse = Schema.Struct({ version: Schema.String, toolCount: Schema.Number, scopes: Schema.Array(Schema.String), + operations: Schema.Array(ProbeOperation), }); const AddSourcePayload = Schema.Struct({ @@ -54,7 +62,7 @@ const AddSourceResponse = Schema.Struct({ const StartOAuthPayload = Schema.Struct({ name: Schema.String, discoveryUrl: Schema.String, - clientId: Schema.String, + clientIdSecretId: Schema.String, clientSecretSecretId: Schema.optional(Schema.NullOr(Schema.String)), redirectUrl: Schema.String, scopes: Schema.optional(Schema.Array(Schema.String)), @@ -74,7 +82,7 @@ const CompleteOAuthPayload = Schema.Struct({ const CompleteOAuthResponse = Schema.Struct({ kind: Schema.Literal("oauth2"), - clientId: Schema.String, + clientIdSecretId: Schema.String, clientSecretSecretId: Schema.NullOr(Schema.String), accessTokenSecretId: Schema.String, refreshTokenSecretId: Schema.NullOr(Schema.String), diff --git a/packages/plugins/google-discovery/src/api/handlers.ts b/packages/plugins/google-discovery/src/api/handlers.ts index 69b6169965..a18460eaab 100644 --- a/packages/plugins/google-discovery/src/api/handlers.ts +++ b/packages/plugins/google-discovery/src/api/handlers.ts @@ -130,7 +130,7 @@ export const GoogleDiscoveryHandlers = HttpApiBuilder.group( return yield* ext.startOAuth({ name: payload.name, discoveryUrl: payload.discoveryUrl, - clientId: payload.clientId, + clientIdSecretId: payload.clientIdSecretId, clientSecretSecretId: payload.clientSecretSecretId, redirectUrl: payload.redirectUrl, scopes: payload.scopes, diff --git a/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx b/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx index fe198927bd..749b295325 100644 --- a/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx +++ b/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx @@ -7,17 +7,40 @@ import { SecretPicker, type SecretPickerSecret } from "@executor/react/plugins/s import { SecretId } from "@executor/sdk"; import { Badge } from "@executor/react/components/badge"; import { Button } from "@executor/react/components/button"; +import { + CardStack, + CardStackContent, + CardStackEntryField, +} from "@executor/react/components/card-stack"; +import { + SourceIdentityFields, + useSourceIdentity, +} from "@executor/react/plugins/source-identity"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@executor/react/components/collapsible"; +import { + Field, + FieldContent, + FieldDescription, + FieldGroup, + FieldLabel, + FieldLegend, + FieldSet, + FieldTitle, +} from "@executor/react/components/field"; +import { FilterTabs } from "@executor/react/components/filter-tabs"; +import { FloatActions } from "@executor/react/components/float-actions"; import { Input } from "@executor/react/components/input"; import { Label } from "@executor/react/components/label"; import { RadioGroup, RadioGroupItem } from "@executor/react/components/radio-group"; -import { Spinner } from "@executor/react/components/spinner"; +import { IOSSpinner, Spinner } from "@executor/react/components/spinner"; import { addGoogleDiscoverySource, probeGoogleDiscovery, startGoogleDiscoveryOAuth } from "./atoms"; +type GoogleAuthKind = "none" | "oauth2"; + // --------------------------------------------------------------------------- // Inline secret creation // --------------------------------------------------------------------------- @@ -61,40 +84,40 @@ function InlineCreateSecret(props: { return (
-

New secret

+

New secret

- + setSecretIdValue((e.target as HTMLInputElement).value)} placeholder="google-client-secret" - className="h-8 text-sm font-mono" + className="h-8 text-xs font-mono" />
-
- + setSecretValue((e.target as HTMLInputElement).value)} placeholder="paste your client secret…" - className="h-8 text-sm font-mono" + className="h-8 text-xs font-mono" />
- {error &&

{error}

} + {error &&

{error}

}
- {clientSecretSecretId && ( + {clearable && secretId && ( @@ -315,6 +343,13 @@ function GoogleServiceIcon(props: { readonly service: string; readonly className ); } +type ProbeOperation = { + toolPath: string; + method: string; + pathTemplate: string; + description: string | null; +}; + type ProbeResult = { name: string; title: string | null; @@ -322,11 +357,12 @@ type ProbeResult = { version: string; toolCount: number; scopes: readonly string[]; + operations: readonly ProbeOperation[]; }; type OAuthAuth = { kind: "oauth2"; - clientId: string; + clientIdSecretId: string; clientSecretSecretId: string | null; accessTokenSecretId: string; refreshTokenSecretId: string | null; @@ -411,16 +447,18 @@ export default function AddGoogleDiscoverySource(props: { const [discoveryUrl, setDiscoveryUrl] = useState( props.initialUrl ?? defaultTemplate.discoveryUrl, ); - const [name, setName] = useState(props.initialUrl ? "" : defaultTemplate.name); const [selectedTemplateId, setSelectedTemplateId] = useState( props.initialUrl ? "" : defaultTemplate.id, ); const selectedTemplate = GOOGLE_DISCOVERY_TEMPLATES.find((template) => template.id === selectedTemplateId) ?? null; - const [authKind, setAuthKind] = useState<"none" | "oauth2">("oauth2"); - const [clientId, setClientId] = useState(""); + const [authKind, setAuthKind] = useState("oauth2"); + const [clientIdSecretId, setClientIdSecretId] = useState(null); const [clientSecretSecretId, setClientSecretSecretId] = useState(null); const [probe, setProbe] = useState(null); + const identity = useSourceIdentity({ + fallbackName: probe?.name ?? selectedTemplate?.name ?? "", + }); const [oauthAuth, setOauthAuth] = useState(null); const [loadingProbe, setLoadingProbe] = useState(false); const [startingOAuth, setStartingOAuth] = useState(false); @@ -446,17 +484,20 @@ export default function AddGoogleDiscoverySource(props: { })), }); - const applyTemplate = useCallback((template: GoogleDiscoveryTemplate) => { - setSelectedTemplateId(template.id); - setDiscoveryUrl(template.discoveryUrl); - setName(template.name); - setClientSecretSecretId(null); - setProbe(null); - setOauthAuth(null); - setError(null); - setShowScopes(false); - setAuthKind("oauth2"); - }, []); + 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); @@ -468,10 +509,11 @@ export default function AddGoogleDiscoverySource(props: { path: { scopeId }, payload: { discoveryUrl: discoveryUrl.trim() }, }); - setProbe({ ...result, scopes: [...result.scopes] }); - if (!name.trim()) { - setName(result.name); - } + setProbe({ + ...result, + scopes: [...result.scopes], + operations: [...result.operations], + }); if (result.scopes.length === 0) { setAuthKind("none"); } @@ -481,20 +523,30 @@ export default function AddGoogleDiscoverySource(props: { } finally { setLoadingProbe(false); } - }, [discoveryUrl, doProbe, name, scopeId]); + }, [discoveryUrl, doProbe, scopeId]); - const autoProbed = useRef(false); + // 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(() => { - if (props.initialUrl && !autoProbed.current) { - autoProbed.current = true; - handleProbe(); - } - }, []); // eslint-disable-line react-hooks/exhaustive-deps + const trimmed = discoveryUrl.trim(); + if (!trimmed) return; + if (probe) return; + const handle = setTimeout(() => { + handleProbeRef.current(); + }, 400); + return () => clearTimeout(handle); + }, [discoveryUrl, probe]); const oauthCleanup = useRef<(() => void) | null>(null); const handleStartOAuth = useCallback(async () => { - if (!probe) return; + if (!probe || !clientIdSecretId) return; oauthCleanup.current?.(); oauthCleanup.current = null; setStartingOAuth(true); @@ -503,9 +555,9 @@ export default function AddGoogleDiscoverySource(props: { const response = await doStartOAuth({ path: { scopeId }, payload: { - name: name.trim() || probe.name, + name: identity.name.trim() || probe.name, discoveryUrl: discoveryUrl.trim(), - clientId: clientId.trim(), + clientIdSecretId, clientSecretSecretId, redirectUrl: `${window.location.origin}/api/google-discovery/oauth/callback`, scopes: probe.scopes, @@ -520,7 +572,7 @@ export default function AddGoogleDiscoverySource(props: { if (result.ok) { setOauthAuth({ kind: "oauth2", - clientId: result.clientId, + clientIdSecretId: result.clientIdSecretId, clientSecretSecretId: result.clientSecretSecretId, accessTokenSecretId: result.accessTokenSecretId, refreshTokenSecretId: result.refreshTokenSecretId, @@ -544,7 +596,7 @@ export default function AddGoogleDiscoverySource(props: { setStartingOAuth(false); setError(e instanceof Error ? e.message : "Failed to start OAuth"); } - }, [probe, doStartOAuth, scopeId, name, discoveryUrl, clientId, clientSecretSecretId]); + }, [probe, doStartOAuth, scopeId, identity, discoveryUrl, clientIdSecretId, clientSecretSecretId]); const handleCancelOAuth = useCallback(() => { oauthCleanup.current?.(); @@ -560,8 +612,9 @@ export default function AddGoogleDiscoverySource(props: { await doAdd({ path: { scopeId }, payload: { - name: name.trim() || probe.name, + name: identity.name.trim() || probe.name, discoveryUrl: discoveryUrl.trim(), + namespace: identity.namespace.trim() || undefined, auth: authKind === "oauth2" ? (oauthAuth ?? { kind: "none" as const }) @@ -573,100 +626,85 @@ export default function AddGoogleDiscoverySource(props: { setError(e instanceof Error ? e.message : "Failed to add source"); setAdding(false); } - }, [probe, doAdd, name, discoveryUrl, authKind, oauthAuth, props, scopeId]); + }, [probe, doAdd, identity, discoveryUrl, authKind, oauthAuth, props, scopeId]); const addDisabled = !probe || adding || (authKind === "oauth2" && (!canUseOAuth || oauthAuth === null)); return ( -
+

Add Google Discovery Source

-

+

Connect a Google API from its Discovery document and register its methods as tools.

-
-
- - - Select a Google API to prefill the source. - -
-
- {GOOGLE_DISCOVERY_TEMPLATES.map((template) => { - const selected = template.id === selectedTemplateId; - return ( - - ); - })} -
-
+ )} +
+ -
- -
- { - setSelectedTemplateId(""); - setDiscoveryUrl((e.target as HTMLInputElement).value); - }} - placeholder="https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest" - className="flex-1 font-mono text-sm" - /> - -
-
+ + -
- - setName((e.target as HTMLInputElement).value)} - placeholder="Google Sheets" - /> -
+ {probe && (
@@ -680,7 +718,7 @@ export default function AddGoogleDiscoverySource(props: {

{probe.title ?? probe.name}

-

+

{probe.service} · {probe.version}

@@ -693,45 +731,44 @@ export default function AddGoogleDiscoverySource(props: { )} -
- setAuthKind(value as "none" | "oauth2")} - className="flex items-center gap-4" - > -
- - -
-
- - -
-
+
+
+ Authentication + + tabs={[ + { value: "none", label: "None" }, + { value: "oauth2", label: "OAuth" }, + ]} + value={authKind} + onChange={setAuthKind} + /> +
{authKind === "oauth2" && (
-
- - setClientId((e.target as HTMLInputElement).value)} - placeholder="1234567890-abc.apps.googleusercontent.com" - /> -
- +
-

+

{canUseOAuth ? `${probe?.scopes.length ?? 0} scopes will be requested from Google.` : "This API does not advertise OAuth scopes."} @@ -752,7 +789,7 @@ export default function AddGoogleDiscoverySource(props: {

)} -
- -
+
); } diff --git a/packages/plugins/google-discovery/src/sdk/invoke.ts b/packages/plugins/google-discovery/src/sdk/invoke.ts index 3e2059343f..9614ab4174 100644 --- a/packages/plugins/google-discovery/src/sdk/invoke.ts +++ b/packages/plugins/google-discovery/src/sdk/invoke.ts @@ -137,8 +137,21 @@ const resolveOAuthAccessToken = (input: { ), ); + const clientId = yield* input.secrets + .resolve(auth.clientIdSecretId as SecretId, input.scopeId) + .pipe( + Effect.mapError( + () => + new ToolInvocationError({ + toolId: "" as ToolId, + message: "Failed to resolve Google OAuth client ID", + cause: undefined, + }), + ), + ); + const refreshed = yield* refreshAccessToken({ - clientId: auth.clientId, + clientId, clientSecret, refreshToken, scopes: auth.scopes, @@ -199,7 +212,7 @@ const resolveOAuthAccessToken = (input: { ...input.source, auth: { kind: "oauth2", - clientId: auth.clientId, + clientIdSecretId: auth.clientIdSecretId, clientSecretSecretId: auth.clientSecretSecretId, accessTokenSecretId: auth.accessTokenSecretId, refreshTokenSecretId, diff --git a/packages/plugins/google-discovery/src/sdk/plugin.test.ts b/packages/plugins/google-discovery/src/sdk/plugin.test.ts index 3e6e149ea5..9b5bb948c4 100644 --- a/packages/plugins/google-discovery/src/sdk/plugin.test.ts +++ b/packages/plugins/google-discovery/src/sdk/plugin.test.ts @@ -160,11 +160,20 @@ describe("Google Discovery plugin", () => { ), ); + await Effect.runPromise( + executor.secrets.set({ + id: SecretId.make("google-client-id"), + name: "Google Client ID", + value: "client-123", + purpose: "google_oauth_client_id", + }), + ); + const result = await Effect.runPromise( executor.googleDiscovery.startOAuth({ name: "Google Drive", discoveryUrl, - clientId: "client-123", + clientIdSecretId: "google-client-id", redirectUrl: "http://localhost/callback", }), ); @@ -189,6 +198,15 @@ describe("Google Discovery plugin", () => { ), ); + await Effect.runPromise( + executor.secrets.set({ + id: SecretId.make("google-client-id"), + name: "Google Client ID", + value: "client-123", + purpose: "google_oauth_client_id", + }), + ); + await Effect.runPromise( executor.secrets.set({ id: SecretId.make("google-client-secret"), @@ -231,7 +249,7 @@ describe("Google Discovery plugin", () => { executor.googleDiscovery.startOAuth({ name: "Google Drive", discoveryUrl, - clientId: "client-123", + clientIdSecretId: "google-client-id", clientSecretSecretId: "google-client-secret", redirectUrl: "http://localhost/callback", }), @@ -245,7 +263,7 @@ describe("Google Discovery plugin", () => { ); expect(auth.kind).toBe("oauth2"); - expect(auth.clientId).toBe("client-123"); + expect(auth.clientIdSecretId).toBe("google-client-id"); expect(auth.refreshTokenSecretId).not.toBeNull(); const accessToken = await Effect.runPromise( @@ -283,6 +301,15 @@ describe("Google Discovery plugin", () => { }), ); + await Effect.runPromise( + executor.secrets.set({ + id: SecretId.make("drive-client-id"), + name: "Drive Client ID", + value: "client-123", + purpose: "google_oauth_client_id", + }), + ); + const result = await Effect.runPromise( executor.googleDiscovery.addSource({ name: "Google Drive", @@ -290,7 +317,7 @@ describe("Google Discovery plugin", () => { namespace: "drive", auth: { kind: "oauth2", - clientId: "client-123", + clientIdSecretId: "drive-client-id", clientSecretSecretId: null, accessTokenSecretId: "drive-access-token", refreshTokenSecretId: null, diff --git a/packages/plugins/google-discovery/src/sdk/plugin.ts b/packages/plugins/google-discovery/src/sdk/plugin.ts index a8ac0b4b6a..ddf3a778e1 100644 --- a/packages/plugins/google-discovery/src/sdk/plugin.ts +++ b/packages/plugins/google-discovery/src/sdk/plugin.ts @@ -36,6 +36,13 @@ import type { } from "./types"; import { GoogleDiscoveryStoredSourceData as GoogleDiscoveryStoredSourceDataSchema } from "./types"; +export interface GoogleDiscoveryProbeOperation { + readonly toolPath: string; + readonly method: string; + readonly pathTemplate: string; + readonly description: string | null; +} + export interface GoogleDiscoveryProbeResult { readonly name: string; readonly title: string | null; @@ -43,6 +50,7 @@ export interface GoogleDiscoveryProbeResult { readonly version: string; readonly toolCount: number; readonly scopes: readonly string[]; + readonly operations: readonly GoogleDiscoveryProbeOperation[]; } export interface GoogleDiscoveryAddSourceInput { @@ -55,7 +63,7 @@ export interface GoogleDiscoveryAddSourceInput { export interface GoogleDiscoveryOAuthStartInput { readonly name: string; readonly discoveryUrl: string; - readonly clientId: string; + readonly clientIdSecretId: string; readonly clientSecretSecretId?: string | null; readonly redirectUrl: string; readonly scopes?: readonly string[]; @@ -75,7 +83,7 @@ export interface GoogleDiscoveryOAuthCompleteInput { export interface GoogleDiscoveryOAuthAuthResult { readonly kind: "oauth2"; - readonly clientId: string; + readonly clientIdSecretId: string; readonly clientSecretSecretId: string | null; readonly accessTokenSecretId: string; readonly refreshTokenSecretId: string | null; @@ -370,6 +378,12 @@ export const googleDiscoveryPlugin = (options?: { const scopes = Object.keys( manifest.oauthScopes._tag === "Some" ? manifest.oauthScopes.value : {}, ).sort(); + const operations = manifest.methods.map((method) => ({ + toolPath: method.toolPath, + method: method.binding.method, + pathTemplate: method.binding.pathTemplate, + description: method.description._tag === "Some" ? method.description.value : null, + })); return { name: manifest.title._tag === "Some" @@ -380,6 +394,7 @@ export const googleDiscoveryPlugin = (options?: { version: manifest.version, toolCount: manifest.methods.length, scopes, + operations, }; }), @@ -437,12 +452,22 @@ export const googleDiscoveryPlugin = (options?: { message: "This Google Discovery document does not declare any OAuth scopes", }); } + const clientId = yield* ctx.secrets + .resolve(SecretId.make(input.clientIdSecretId), ctx.scope.id) + .pipe( + Effect.mapError( + (error) => + new GoogleDiscoveryOAuthError({ + message: error.message, + }), + ), + ); const sessionId = randomUUID(); const codeVerifier = createPkceCodeVerifier(); oauthSessions.set(sessionId, { discoveryUrl: normalizeDiscoveryUrl(input.discoveryUrl), name: input.name, - clientId: input.clientId, + clientIdSecretId: input.clientIdSecretId, clientSecretSecretId: input.clientSecretSecretId ?? null, redirectUrl: input.redirectUrl, scopes, @@ -451,7 +476,7 @@ export const googleDiscoveryPlugin = (options?: { return { sessionId, authorizationUrl: buildGoogleAuthorizationUrl({ - clientId: input.clientId, + clientId, redirectUrl: input.redirectUrl, scopes, state: sessionId, @@ -482,8 +507,18 @@ export const googleDiscoveryPlugin = (options?: { }); } + const clientId = yield* ctx.secrets + .resolve(SecretId.make(session.clientIdSecretId), ctx.scope.id) + .pipe( + Effect.mapError( + (error) => + new GoogleDiscoveryOAuthError({ + message: error.message, + }), + ), + ); const tokenResponse = yield* exchangeAuthorizationCode({ - clientId: session.clientId, + clientId, clientSecret: session.clientSecretSecretId === null ? null @@ -518,7 +553,7 @@ export const googleDiscoveryPlugin = (options?: { : null; return { kind: "oauth2" as const, - clientId: session.clientId, + clientIdSecretId: session.clientIdSecretId, clientSecretSecretId: session.clientSecretSecretId, accessTokenSecretId: accessTokenRef.id, refreshTokenSecretId: refreshTokenRef?.id ?? null, diff --git a/packages/plugins/google-discovery/src/sdk/types.ts b/packages/plugins/google-discovery/src/sdk/types.ts index 3787be0c82..1ff83d279d 100644 --- a/packages/plugins/google-discovery/src/sdk/types.ts +++ b/packages/plugins/google-discovery/src/sdk/types.ts @@ -66,7 +66,7 @@ export const GoogleDiscoveryAuth = Schema.Union( }), Schema.Struct({ kind: Schema.Literal("oauth2"), - clientId: Schema.String, + clientIdSecretId: Schema.String, clientSecretSecretId: Schema.NullOr(Schema.String), accessTokenSecretId: Schema.String, refreshTokenSecretId: Schema.NullOr(Schema.String), @@ -107,7 +107,7 @@ export interface GoogleDiscoverySourceMeta { export interface GoogleDiscoveryOAuthSession { readonly discoveryUrl: string; readonly name: string; - readonly clientId: string; + readonly clientIdSecretId: string; readonly clientSecretSecretId: string | null; readonly redirectUrl: string; readonly scopes: readonly string[]; diff --git a/packages/plugins/graphql/src/api/group.ts b/packages/plugins/graphql/src/api/group.ts index 6d01ff7ab1..a28e938529 100644 --- a/packages/plugins/graphql/src/api/group.ts +++ b/packages/plugins/graphql/src/api/group.ts @@ -18,12 +18,14 @@ const namespaceParam = HttpApiSchema.param("namespace", Schema.String); const AddSourcePayload = Schema.Struct({ endpoint: Schema.String, + name: Schema.optional(Schema.String), introspectionJson: Schema.optional(Schema.String), namespace: Schema.optional(Schema.String), headers: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Unknown })), }); const UpdateSourcePayload = Schema.Struct({ + name: Schema.optional(Schema.String), endpoint: Schema.optional(Schema.String), headers: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Unknown })), }); diff --git a/packages/plugins/graphql/src/api/handlers.ts b/packages/plugins/graphql/src/api/handlers.ts index bc398bacdd..ab57cd0a12 100644 --- a/packages/plugins/graphql/src/api/handlers.ts +++ b/packages/plugins/graphql/src/api/handlers.ts @@ -31,6 +31,7 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra const ext = yield* GraphqlExtensionService; const result = yield* ext.addSource({ endpoint: payload.endpoint, + name: payload.name, introspectionJson: payload.introspectionJson, namespace: payload.namespace, headers: payload.headers as Record | undefined, @@ -51,6 +52,7 @@ export const GraphqlHandlers = HttpApiBuilder.group(ExecutorApiWithGraphql, "gra Effect.gen(function* () { const ext = yield* GraphqlExtensionService; yield* ext.updateSource(path.namespace, { + name: payload.name, endpoint: payload.endpoint, headers: payload.headers as Record | undefined, } as GraphqlUpdateSourceInput); diff --git a/packages/plugins/graphql/src/react/AddGraphqlSource.tsx b/packages/plugins/graphql/src/react/AddGraphqlSource.tsx index aa28c771a1..adc75b83df 100644 --- a/packages/plugins/graphql/src/react/AddGraphqlSource.tsx +++ b/packages/plugins/graphql/src/react/AddGraphqlSource.tsx @@ -2,23 +2,28 @@ import { useState } from "react"; import { useAtomSet } from "@effect-atom/atom-react"; import { useScope } from "@executor/react/api/scope-context"; -import { SecretHeaderAuthRow } from "@executor/react/plugins/secret-header-auth"; +import { HeadersList } from "@executor/react/plugins/headers-list"; +import { type HeaderState } from "@executor/react/plugins/secret-header-auth"; +import { + displayNameFromUrl, + SourceIdentityFields, + useSourceIdentity, +} from "@executor/react/plugins/source-identity"; import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets"; import { Button } from "@executor/react/components/button"; +import { + CardStack, + CardStackContent, + CardStackEntryField, +} from "@executor/react/components/card-stack"; +import { FieldLabel } from "@executor/react/components/field"; +import { FloatActions } from "@executor/react/components/float-actions"; import { Input } from "@executor/react/components/input"; -import { Label } from "@executor/react/components/label"; import { Spinner } from "@executor/react/components/spinner"; import { addGraphqlSource } from "./atoms"; import type { HeaderValue } from "../sdk/types"; -type HeaderEntry = { - name: string; - prefix?: string; - presetKey?: string; - secretId: string | null; -}; - -const initialHeader = (): HeaderEntry => ({ +const initialHeader = (): HeaderState => ({ name: "Authorization", prefix: "Bearer ", presetKey: "bearer", @@ -31,8 +36,10 @@ export default function AddGraphqlSource(props: { initialUrl?: string; }) { const [endpoint, setEndpoint] = useState(props.initialUrl ?? ""); - const [namespace, setNamespace] = useState(""); - const [headers, setHeaders] = useState([initialHeader()]); + const identity = useSourceIdentity({ + fallbackName: displayNameFromUrl(endpoint) ?? "", + }); + const [headers, setHeaders] = useState([initialHeader()]); const [adding, setAdding] = useState(false); const [addError, setAddError] = useState(null); @@ -43,26 +50,6 @@ export default function AddGraphqlSource(props: { const headersValid = headers.every((header) => header.name.trim() && header.secretId); const canAdd = endpoint.trim().length > 0 && (headers.length === 0 || headersValid); - const updateHeader = ( - index: number, - update: Partial<{ name: string; prefix?: string; presetKey?: string; secretId: string | null }>, - ) => { - setHeaders((current) => - current.map((header, i) => (i === index ? { ...header, ...update } : header)), - ); - }; - - const removeHeader = (index: number) => { - setHeaders((current) => current.filter((_, i) => i !== index)); - }; - - const addHeader = () => { - setHeaders((current) => [ - ...current, - { name: "", prefix: undefined, presetKey: undefined, secretId: null }, - ]); - }; - const handleAdd = async () => { setAdding(true); setAddError(null); @@ -82,7 +69,8 @@ export default function AddGraphqlSource(props: { path: { scopeId }, payload: { endpoint: endpoint.trim(), - namespace: namespace.trim() || undefined, + name: identity.name.trim() || undefined, + namespace: identity.namespace.trim() || undefined, ...(Object.keys(headerMap).length > 0 ? { headers: headerMap } : {}), }, }); @@ -94,89 +82,47 @@ export default function AddGraphqlSource(props: { }; return ( -
+

Add GraphQL Source

- {/* Endpoint */} -
- - setEndpoint((e.target as HTMLInputElement).value)} - placeholder="https://api.example.com/graphql" - className="font-mono text-sm" - /> -

- The endpoint will be introspected to discover available queries and mutations. -

-
- - {/* Namespace */} -
- - setNamespace((e.target as HTMLInputElement).value)} - placeholder="my_api" - className="font-mono text-sm" - /> -

- A prefix for the tool names. Derived from the endpoint hostname if not provided. -

-
- - {/* Authentication */} -
-
-
- -

- Secret-backed headers sent with every request, including introspection. -

-
- -
+ setEndpoint((e.target as HTMLInputElement).value)} + placeholder="https://api.example.com/graphql" + className="font-mono text-sm" + /> + + + + + - {headers.length > 0 && ( -
- {headers.map((header, index) => ( - updateHeader(index, update)} - onSelectSecret={(secretId) => updateHeader(index, { secretId })} - onRemove={() => removeHeader(index)} - existingSecrets={secretList} - /> - ))} -
- )} +
+ Headers +
{/* Error */} {addError && (
-

{addError}

+

{addError}

)} - {/* Actions */} -
+ @@ -184,7 +130,7 @@ export default function AddGraphqlSource(props: { {adding && } {adding ? "Adding..." : "Add source"} -
+
); } diff --git a/packages/plugins/graphql/src/react/EditGraphqlSource.tsx b/packages/plugins/graphql/src/react/EditGraphqlSource.tsx index 7185411d52..b946263d77 100644 --- a/packages/plugins/graphql/src/react/EditGraphqlSource.tsx +++ b/packages/plugins/graphql/src/react/EditGraphqlSource.tsx @@ -4,14 +4,23 @@ import { graphqlSourceAtom, updateGraphqlSource } from "./atoms"; import { useScope } from "@executor/react/api/scope-context"; import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets"; import { - SecretHeaderAuthRow, headerValueToState, headersFromState, type HeaderState, } from "@executor/react/plugins/secret-header-auth"; +import { HeadersList } from "@executor/react/plugins/headers-list"; +import { + SourceIdentityFields, + useSourceIdentity, +} from "@executor/react/plugins/source-identity"; import { Button } from "@executor/react/components/button"; +import { + CardStack, + CardStackContent, + CardStackEntryField, +} from "@executor/react/components/card-stack"; +import { FieldLabel } from "@executor/react/components/field"; import { Input } from "@executor/react/components/input"; -import { Label } from "@executor/react/components/label"; import { Badge } from "@executor/react/components/badge"; import type { StoredSourceSchemaType } from "../sdk/stored-source"; @@ -29,6 +38,10 @@ function EditForm(props: { const refreshSource = useAtomRefresh(graphqlSourceAtom(scopeId, props.sourceId)); const secretList = useSecretPickerSecrets(); + const identity = useSourceIdentity({ + fallbackName: props.initial.name, + fallbackNamespace: props.initial.namespace, + }); const [endpoint, setEndpoint] = useState(props.initial.config.endpoint); const [headers, setHeaders] = useState(() => Object.entries(props.initial.config.headers ?? {}).map(([name, value]) => @@ -39,8 +52,10 @@ function EditForm(props: { const [error, setError] = useState(null); const [dirty, setDirty] = useState(false); - const updateHeader = (index: number, update: Partial) => { - setHeaders((prev) => prev.map((h, i) => (i === index ? { ...h, ...update } : h))); + const identityDirty = identity.name.trim() !== props.initial.name.trim(); + + const handleHeadersChange = (next: HeaderState[]) => { + setHeaders(next); setDirty(true); }; @@ -51,6 +66,7 @@ function EditForm(props: { await doUpdate({ path: { scopeId, namespace: props.sourceId }, payload: { + name: identity.name.trim() || undefined, endpoint: endpoint.trim() || undefined, headers: headersFromState(headers), }, @@ -83,48 +99,31 @@ function EditForm(props: {
-
- - { - setEndpoint((e.target as HTMLInputElement).value); - setDirty(true); - }} - placeholder="https://api.example.com/graphql" - className="font-mono text-sm" - /> -
+ + + + + + { + setEndpoint((e.target as HTMLInputElement).value); + setDirty(true); + }} + placeholder="https://api.example.com/graphql" + className="font-mono text-sm" + /> + + +
- - {headers.map((h, i) => ( - updateHeader(i, update)} - onSelectSecret={(secretId) => updateHeader(i, { secretId })} - onRemove={() => { - setHeaders((prev) => prev.filter((_, j) => j !== i)); - setDirty(true); - }} - existingSecrets={secretList} - /> - ))} - + Headers +
{error && ( @@ -137,7 +136,7 @@ function EditForm(props: { -
diff --git a/packages/plugins/graphql/src/sdk/plugin.ts b/packages/plugins/graphql/src/sdk/plugin.ts index 3458a8216a..2206c5aca1 100644 --- a/packages/plugins/graphql/src/sdk/plugin.ts +++ b/packages/plugins/graphql/src/sdk/plugin.ts @@ -45,6 +45,8 @@ export type HeaderValue = HeaderValueValue; export interface GraphqlSourceConfig { /** The GraphQL endpoint URL */ readonly endpoint: string; + /** Display name for the source. Falls back to namespace if not provided. */ + readonly name?: string; /** Optional: introspection JSON text (if endpoint doesn't support introspection) */ readonly introspectionJson?: string; /** Namespace for the tools (derived from endpoint if not provided) */ @@ -58,6 +60,7 @@ export interface GraphqlSourceConfig { // --------------------------------------------------------------------------- export interface GraphqlUpdateSourceInput { + readonly name?: string; readonly endpoint?: string; readonly headers?: Record; } @@ -87,6 +90,7 @@ export interface GraphqlPluginExtension { const AddSourceInputSchema = Schema.Struct({ endpoint: Schema.String, + name: Schema.optional(Schema.String), introspectionJson: Schema.optional(Schema.String), namespace: Schema.optional(Schema.String), headers: Schema.optional(Schema.Record({ key: Schema.String, value: HeaderValueSchema })), @@ -379,7 +383,7 @@ export const graphqlPlugin = (options?: { yield* operationStore.putSource({ namespace, - name: namespace, + name: config.name?.trim() || namespace, config: { endpoint: config.endpoint, introspectionJson: config.introspectionJson, @@ -472,7 +476,7 @@ export const graphqlPlugin = (options?: { yield* operationStore.putSource({ namespace, - name: existingMeta?.name ?? namespace, + name: input.name?.trim() || existingMeta?.name || namespace, config: updatedConfig, }); }), diff --git a/packages/plugins/onepassword/src/react/OnePasswordSettings.tsx b/packages/plugins/onepassword/src/react/OnePasswordSettings.tsx index a5eb2daf98..04f1daa994 100644 --- a/packages/plugins/onepassword/src/react/OnePasswordSettings.tsx +++ b/packages/plugins/onepassword/src/react/OnePasswordSettings.tsx @@ -21,6 +21,12 @@ import { DialogFooter, DialogClose, } from "@executor/react/components/dialog"; +import { + CardStackEntry, + CardStackEntryActions, + CardStackEntryContent, + CardStackEntryDescription, +} from "@executor/react/components/card-stack"; import { onepasswordConfigAtom, @@ -74,7 +80,7 @@ function VaultPicker(props: { if (!account) { return ( -

+

Enter account details to load vaults.

); @@ -90,7 +96,7 @@ function VaultPicker(props: { if (v) props.onVaultSelect(v.id, v.name); }} > - + @@ -103,7 +109,7 @@ function VaultPicker(props: { {error && (
-

+

{error}

@@ -182,7 +188,7 @@ function ConfigDialog(props: { {isEdit ? "Edit 1Password" : "Connect 1Password"} - + Link a vault to resolve secrets via the 1Password desktop app or a service account. @@ -190,14 +196,14 @@ function ConfigDialog(props: {
{/* Auth method */}
-
@@ -317,92 +323,70 @@ export default function OnePasswordSettings() { }); return ( -
- {/* Header */} -
-
-
-

1Password

- {isLoading ? ( - - ) : isError ? ( - - Error - - ) : config ? ( - - Connected - - ) : ( - - Not configured - - )} -
-
- {config && ( -
- - -
- )} -
- - {/* Body */} -
- {isLoading ? ( -
-
-

Loading…

-
- ) : isError ? ( -
-

Failed to load configuration

-
- ) : config ? ( -
- Auth - - {config.auth.kind === "desktop-app" ? config.auth.accountName : "service-account"} - - Vault -
- {config.name} - - {config.vaultId} + <> + + + {isLoading ? ( + Loading… + ) : isError ? ( + + Failed to load configuration + + ) : config ? ( +
+ Auth + + {config.auth.kind === "desktop-app" ? config.auth.accountName : "service-account"} + Vault +
+ {config.name} + + {config.vaultId} + +
-
- ) : ( -
-

+ ) : ( + Resolve secrets from your 1Password vault. -

- -
- )} -
+ + )} + + + {config ? ( + <> + + + + ) : ( + !isLoading && + !isError && ( + + ) + )} + + {configOpen && ( )} -
+ ); } diff --git a/packages/plugins/openapi/src/api/group.ts b/packages/plugins/openapi/src/api/group.ts index 092d931c6c..4037a9f436 100644 --- a/packages/plugins/openapi/src/api/group.ts +++ b/packages/plugins/openapi/src/api/group.ts @@ -30,6 +30,7 @@ const PreviewSpecPayload = Schema.Struct({ }); const UpdateSourcePayload = Schema.Struct({ + name: Schema.optional(Schema.String), baseUrl: Schema.optional(Schema.String), headers: Schema.optional(Schema.Record({ key: Schema.String, value: Schema.Unknown })), }); diff --git a/packages/plugins/openapi/src/api/handlers.ts b/packages/plugins/openapi/src/api/handlers.ts index ad4f28c856..5fd814e72f 100644 --- a/packages/plugins/openapi/src/api/handlers.ts +++ b/packages/plugins/openapi/src/api/handlers.ts @@ -58,6 +58,7 @@ export const OpenApiHandlers = HttpApiBuilder.group(ExecutorApiWithOpenApi, "ope Effect.gen(function* () { const ext = yield* OpenApiExtensionService; yield* ext.updateSource(path.namespace, { + name: payload.name, baseUrl: payload.baseUrl, headers: payload.headers as Record | undefined, } as OpenApiUpdateSourceInput); diff --git a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx index 9759d5e260..fb458a8071 100644 --- a/packages/plugins/openapi/src/react/AddOpenApiSource.tsx +++ b/packages/plugins/openapi/src/react/AddOpenApiSource.tsx @@ -1,20 +1,36 @@ -import { useState, useEffect, useRef } from "react"; +import { useEffect, useMemo, useRef, useState } from "react"; import { useAtomSet } from "@effect-atom/atom-react"; import { Option } from "effect"; import { useScope } from "@executor/react/api/scope-context"; +import { HeadersList } from "@executor/react/plugins/headers-list"; import { - SecretHeaderAuthRow, - defaultHeaderAuthPresets, + matchPresetKey, + type HeaderState, } from "@executor/react/plugins/secret-header-auth"; +import { + SourceIdentityFields, + useSourceIdentity, +} from "@executor/react/plugins/source-identity"; import { useSecretPickerSecrets } from "@executor/react/plugins/use-secret-picker-secrets"; import { Button } from "@executor/react/components/button"; +import { + CardStack, + CardStackContent, + CardStackEntry, + CardStackEntryContent, + CardStackEntryDescription, + CardStackEntryField, + CardStackEntryTitle, +} from "@executor/react/components/card-stack"; +import { FieldLabel } from "@executor/react/components/field"; +import { FloatActions } from "@executor/react/components/float-actions"; import { Input } from "@executor/react/components/input"; import { Label } from "@executor/react/components/label"; import { Textarea } from "@executor/react/components/textarea"; -import { Badge } from "@executor/react/components/badge"; import { RadioGroup, RadioGroupItem } from "@executor/react/components/radio-group"; -import { Spinner } from "@executor/react/components/spinner"; +import { Skeleton } from "@executor/react/components/skeleton"; +import { IOSSpinner, Spinner } from "@executor/react/components/spinner"; import { previewOpenApiSpec, addOpenApiSpec } from "./atoms"; import type { SpecPreview, HeaderPreset } from "../sdk/preview"; import type { HeaderValue } from "../sdk/types"; @@ -32,20 +48,12 @@ function prefixForHeader(preset: HeaderPreset, headerName: string): string | und return undefined; } -function matchPresetKey(name: string, prefix?: string): string { - const preset = - defaultHeaderAuthPresets.find((entry) => entry.name === name && entry.prefix === prefix) ?? - defaultHeaderAuthPresets.find((entry) => entry.name === name && entry.prefix === undefined); - - return preset?.key ?? "custom"; -} - -function presetEntriesFromHeaderPreset(preset: HeaderPreset) { +function entriesFromSpecPreset(preset: HeaderPreset): HeaderState[] { return preset.secretHeaders.map((headerName) => { const prefix = prefixForHeader(preset, headerName); return { name: headerName, - secretId: null as string | null, + secretId: null, prefix, presetKey: matchPresetKey(headerName, prefix), fromPreset: true, @@ -71,20 +79,16 @@ export default function AddOpenApiSource(props: { // After analysis const [preview, setPreview] = useState(null); const [baseUrl, setBaseUrl] = useState(""); - const [namespace, setNamespace] = useState(props.initialNamespace ?? ""); - const [sourceName, setSourceName] = useState(""); + const identity = useSourceIdentity({ + fallbackName: preview ? Option.getOrElse(preview.title, () => "") : "", + fallbackNamespace: props.initialNamespace, + }); // Auth - const [presetIndex, setPresetIndex] = useState(0); - const [customHeaders, setCustomHeaders] = useState< - Array<{ - name: string; - secretId: string | null; - prefix?: string; - presetKey?: string; - fromPreset?: boolean; - }> - >([]); + // `selectedStrategy` is an index into `preview.headerPresets`, or -1 for + // "None", or -2 for "Custom" (user-managed headers, no spec preset). + const [selectedStrategy, setSelectedStrategy] = useState(-1); + const [customHeaders, setCustomHeaders] = useState([]); // Submit const [adding, setAdding] = useState(false); @@ -94,21 +98,47 @@ export default function AddOpenApiSource(props: { const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promise" }); const doAdd = useAtomSet(addOpenApiSpec, { mode: "promise" }); const secretList = useSecretPickerSecrets(); - const autoAnalyzed = useRef(false); + // Keep the latest handleAnalyze in a ref so the debounced effect doesn't + // need it as a dependency (it closes over fresh state). + const handleAnalyzeRef = useRef<() => void>(() => {}); + + // Auto-analyze whenever the spec input changes, with a short debounce so + // typing/pasting doesn't fire a request on every keystroke. useEffect(() => { - if (props.initialUrl && !autoAnalyzed.current) { - autoAnalyzed.current = true; - handleAnalyze(); - } - }, []); // eslint-disable-line react-hooks/exhaustive-deps + const trimmed = specUrl.trim(); + if (!trimmed) return; + if (preview) return; + const handle = setTimeout(() => { + handleAnalyzeRef.current(); + }, 400); + return () => clearTimeout(handle); + }, [specUrl, preview]); // ---- Derived state ---- - const presets = preview?.headerPresets ?? []; - const hasAuth = presets.length > 0; const servers = (preview?.servers ?? []) as Array<{ url?: string }>; + // Derive a favicon URL from the spec URL (if the user entered one — raw + // JSON/YAML content will fail URL parsing and yield null). Uses Google's + // favicon service so we don't depend on the domain serving /favicon.ico. + const faviconUrl = useMemo(() => { + try { + const trimmed = specUrl.trim(); + if (!trimmed) return null; + const u = new URL(trimmed); + if (u.protocol !== "http:" && u.protocol !== "https:") return null; + return `https://www.google.com/s2/favicons?domain=${u.hostname}&sz=64`; + } catch { + return null; + } + }, [specUrl]); + + const [faviconFailed, setFaviconFailed] = useState(false); + useEffect(() => { + setFaviconFailed(false); + }, [faviconUrl]); + const allHeaders: Record = {}; for (const ch of customHeaders) { if (ch.name.trim() && ch.secretId) { @@ -140,28 +170,17 @@ export default function AddOpenApiSource(props: { }); setPreview(result); - // Derive defaults from the title - const title = Option.getOrElse(result.title, () => "api"); - if (!sourceName) setSourceName(title); - if (!props.initialNamespace) { - setNamespace( - title - .toLowerCase() - .replace(/[^a-z0-9]+/g, "_") - .replace(/^_+|_+$/g, "") || "api", - ); - } - const firstUrl = (result.servers as Array<{ url?: string }>)?.[0]?.url; if (firstUrl) setBaseUrl(firstUrl); - const newPresetIndex = result.headerPresets.length > 0 ? 0 : -1; - setPresetIndex(newPresetIndex); - setCustomHeaders( - newPresetIndex >= 0 - ? presetEntriesFromHeaderPreset(result.headerPresets[newPresetIndex]) - : [], - ); + const firstPreset = result.headerPresets[0]; + if (firstPreset) { + setSelectedStrategy(0); + setCustomHeaders(entriesFromSpecPreset(firstPreset)); + } else { + setSelectedStrategy(-1); + setCustomHeaders([]); + } } catch (e) { setAnalyzeError(e instanceof Error ? e.message : "Failed to parse spec"); } finally { @@ -169,41 +188,33 @@ export default function AddOpenApiSource(props: { } }; - const selectPreset = (index: number) => { - setPresetIndex(index); + handleAnalyzeRef.current = handleAnalyze; + + const selectStrategy = (index: number) => { + setSelectedStrategy(index); if (index === -1) { - // "None" — clear everything setCustomHeaders([]); - } else if (index === -2) { - // "Custom" — keep user headers, drop preset-derived, seed if empty - const userHeaders = customHeaders.filter((h) => !h.fromPreset); - setCustomHeaders( - userHeaders.length > 0 ? userHeaders : [{ name: "", secretId: null, presetKey: undefined }], - ); - } else { - // Preset strategy — replace preset-derived headers, keep user headers - const preset = presets[index]; + return; + } + if (index === -2) { + // Drop preset-derived headers, keep user headers (seed one if empty). const userHeaders = customHeaders.filter((h) => !h.fromPreset); - setCustomHeaders( - preset ? [...presetEntriesFromHeaderPreset(preset), ...userHeaders] : userHeaders, - ); + setCustomHeaders(userHeaders.length > 0 ? userHeaders : []); + return; } + const preset = preview?.headerPresets[index]; + if (!preset) return; + const userHeaders = customHeaders.filter((h) => !h.fromPreset); + setCustomHeaders([...entriesFromSpecPreset(preset), ...userHeaders]); }; - const addCustomHeader = () => { - if (presetIndex === -1) setPresetIndex(-2); - setCustomHeaders([...customHeaders, { name: "", secretId: null, presetKey: undefined }]); - }; - - const updateCustomHeader = ( - index: number, - update: Partial<{ name: string; secretId: string | null; prefix?: string; presetKey?: string }>, - ) => { - setCustomHeaders(customHeaders.map((ch, i) => (i === index ? { ...ch, ...update } : ch))); - }; - - const removeCustomHeader = (index: number) => { - setCustomHeaders(customHeaders.filter((_, i) => i !== index)); + const handleHeadersChange = (next: HeaderState[]) => { + setCustomHeaders(next); + // If user drops all preset-derived headers and adds their own, mark as + // Custom so the strategy picker reflects it. + if (selectedStrategy >= 0 && next.every((h) => !h.fromPreset)) { + setSelectedStrategy(next.length === 0 ? -1 : -2); + } }; const handleAdd = async () => { @@ -214,8 +225,8 @@ export default function AddOpenApiSource(props: { path: { scopeId }, payload: { spec: specUrl, - name: sourceName.trim() || undefined, - namespace: namespace.trim() || undefined, + name: identity.name.trim() || undefined, + namespace: identity.namespace.trim() || undefined, baseUrl: baseUrl.trim() || undefined, ...(hasHeaders ? { headers: allHeaders } : {}), }, @@ -230,274 +241,227 @@ export default function AddOpenApiSource(props: { // ---- Render ---- return ( -
+

Add OpenAPI Source

{/* ── Spec input ── */} -
- -