import { useCallback, useEffect, useMemo, useRef, useState } from "react";import { useAtomSet, useAtomValue } from "@effect/atom-react";import { Link } from "@tanstack/react-router";import * as Effect from "effect/Effect";import * as Exit from "effect/Exit";import * as Option from "effect/Option";import * as Predicate from "effect/Predicate";import * as Schema from "effect/Schema";import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { AuthTemplateSlug, IntegrationSlug, type OAuthAuthentication,} from "@executor-js/sdk/shared";import { integrationsOptimisticAtom } from "@executor-js/react/api/atoms";import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys";import { slugifyNamespace, useIntegrationIdentity,} from "@executor-js/react/plugins/integration-identity";import { Button } from "@executor-js/react/components/button";import { AuthTemplateEditor, type AuthTemplateEditorValue,} from "@executor-js/react/components/auth-template-editor";import { CardStack, CardStackContent } from "@executor-js/react/components/card-stack";import { FieldLabel } from "@executor-js/react/components/field";import { FloatActions } from "@executor-js/react/components/float-actions";import { Textarea } from "@executor-js/react/components/textarea";import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner";import { PlusIcon, XIcon } from "lucide-react"; import { authenticationFromEditorValue, editorValueFromAuthentication } from "./auth-method-config";import { addOpenApiSpec, previewOpenApiSpec } from "./atoms";import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields";import { GoogleProductPicker } from "./GoogleProductPicker";import { openApiPresets } from "../sdk/presets";import { GOOGLE_BUNDLE_PRESET_ID, googleOpenApiPresets, type GoogleOpenApiPreset,} from "../sdk/google-presets";import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview";import { type APIKeyAuthentication, type Authentication, type ServerInfo, TOKEN_VARIABLE, variable,} from "../sdk/types";import { expandServerUrlOptions } from "../sdk/openapi-utils"; const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/";const GOOGLE_BUNDLE_FAVICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg"; // The bundle picker opens with the featured Google APIs pre-checked.const googleBundleDefaultPresetIds: ReadonlySet<string> = new Set( googleOpenApiPresets .filter((preset: GoogleOpenApiPreset) => preset.featured) .map((preset: GoogleOpenApiPreset) => preset.id),); const googleBundleUrls = ( selectedPresetIds: ReadonlySet<string>, customUrls: readonly string[],): readonly string[] => { const fromPresets = googleOpenApiPresets.flatMap((preset: GoogleOpenApiPreset) => preset.url && selectedPresetIds.has(preset.id) ? [preset.url] : [], ); // Preset URLs first (stable order), then any custom Discovery URLs, de-duped. return [...new Set([...fromPresets, ...customUrls])];}; 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, }); const isIntegrationAlreadyExistsExit = (exit: Exit.Exit<unknown, unknown>): boolean => Option.match(Exit.findErrorOption(exit), { onNone: () => false, onSome: Predicate.isTagged("IntegrationAlreadyExistsError"), }); const integrationExistsMessage = (slug: string): string => `An integration named "${slug}" already exists. To add more authentication, update your existing integration.`; // ---------------------------------------------------------------------------// OpenAPI url helpers — specs sometimes ship relative OAuth endpoints; resolve// them against the chosen base URL so the stored auth template is absolute.// --------------------------------------------------------------------------- export function resolveOAuthUrl(url: string, baseUrl: string): string { if (!url) return url; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor normalizes provider metadata URLs try { new URL(url); return url; } catch { if (!baseUrl) return url; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor resolves relative provider metadata URLs try { return new URL(url, baseUrl).toString(); } catch { return url; } }} const standardOidcIdentityScopes = ["openid", "email", "profile"] as const; const identityScopesForPreset = ( identityScopes: OAuth2Preset["identityScopes"],): readonly string[] => { if (identityScopes === false) return []; return identityScopes === "auto" ? standardOidcIdentityScopes : identityScopes;}; const resolvedOAuthScopes = ( apiScopes: Iterable<string>, identityScopes: OAuth2Preset["identityScopes"],): string[] => { const merged = new Set(apiScopes); for (const scope of identityScopesForPreset(identityScopes)) merged.add(scope); return [...merged];}; const isGoogleDiscoveryUrl = (url: string): boolean => { const trimmed = url.trim(); if (!URL.canParse(trimmed)) return false; const parsed = new URL(trimmed); const host = parsed.hostname.toLowerCase(); if (!host.endsWith("googleapis.com")) return false; return parsed.pathname.includes("/discovery/") || parsed.pathname.includes("$discovery");}; const normalizePresetUrl = (url: string): string => { const trimmed = url.trim(); if (!URL.canParse(trimmed)) return trimmed.replace(/\/$/, ""); const parsed = new URL(trimmed); parsed.hash = ""; parsed.searchParams.sort(); return parsed.toString().replace(/\/$/, "");}; const specInputForAdd = (input: string) => { const value = input.trim(); const parsed = Effect.runSyncExit( Effect.try({ try: () => new URL(value), catch: () => null, }), ); return Exit.isSuccess(parsed) ? isGoogleDiscoveryUrl(value) ? { kind: "googleDiscovery" as const, url: value } : { kind: "url" as const, url: value } : { kind: "blob" as const, value };}; // ---------------------------------------------------------------------------// Auth-template builders — turn a preview preset into the integration's stored// `Authentication` template (v2). The header preset becomes an `apiKey` template// whose secret header value renders the resolved credential via `variable(token)`;// the oauth2 preset becomes an `oauth` template carrying the provider endpoints.//// Post-redesign the add flow no longer asks the user to pick ONE method: every// spec-detected method is registered so the integration's detail hub can list// them and Add-account can choose among them (P6: add without auth, connect// later).// --------------------------------------------------------------------------- const headerPrefix = (preset: HeaderPreset, headerName: string): string | undefined => { const label = preset.label.toLowerCase(); if (headerName.toLowerCase() === "authorization") { if (label.includes("bearer")) return "Bearer "; if (label.includes("basic")) return "Basic "; } return undefined;}; const apiKeyTemplateFromHeaderPreset = ( preset: HeaderPreset, slug: AuthTemplateSlug,): APIKeyAuthentication => { const headers: Record<string, (string | ReturnType<typeof variable>)[]> = {}; for (const headerName of preset.secretHeaders) { const prefix = headerPrefix(preset, headerName); headers[headerName] = prefix ? [prefix, variable(TOKEN_VARIABLE)] : [variable(TOKEN_VARIABLE)]; } return { slug, type: "apiKey", headers };}; const oauthTemplateFromPreset = ( preset: OAuth2Preset, baseUrl: string, slug: AuthTemplateSlug, scopes: readonly string[],): OAuthAuthentication => ({ slug, type: "oauth", authorizationUrl: resolveOAuthUrl( Option.getOrElse(preset.authorizationUrl, () => ""), baseUrl, ), tokenUrl: resolveOAuthUrl(preset.tokenUrl, baseUrl), scopes: [...scopes],}); const expandServerOptions = (server: ServerInfo) => expandServerUrlOptions(server).map((value) => ({ value, label: value })); const firstBaseUrlForPreview = (preview: SpecPreview): string => { const firstServer = preview.servers[0]; return firstServer ? (expandServerUrlOptions(firstServer)[0] ?? "") : "";}; // ---------------------------------------------------------------------------// All spec-detected auth methods → the union of stored `Authentication`// templates. Header presets become apiKey templates; each oauth2 preset becomes// an oauth template (with its declared API scopes plus, for auth-code flows,// the standard identity scopes). Slugs stay deterministic per method so the// stored template is stable across previews of the same spec. Adding an// integration whose slug already exists is blocked (see the existing-slug// guard below); to add more auth, update the existing integration instead.// --------------------------------------------------------------------------- const detectedAuthenticationTemplates = ( headerPresets: readonly HeaderPreset[], oauth2Presets: readonly OAuth2Preset[], baseUrl: string,): readonly Authentication[] => { const templates: Authentication[] = []; headerPresets.forEach((preset, index) => { templates.push( apiKeyTemplateFromHeaderPreset(preset, AuthTemplateSlug.make(`apikey-${index}`)), ); }); for (const preset of oauth2Presets) { const scopes = resolvedOAuthScopes(Object.keys(preset.scopes), preset.identityScopes); templates.push( oauthTemplateFromPreset( preset, baseUrl, AuthTemplateSlug.make(`oauth-${preset.securitySchemeName}`), scopes, ), ); } return templates;}; // ---------------------------------------------------------------------------// Component — single progressive form. Post-redesign: preview → addSpec// (register the integration catalog entry with ALL detected auth methods) →// route to the integration's detail hub, where the user adds accounts. The add// flow no longer creates a connection.// --------------------------------------------------------------------------- export default function AddOpenApiSource(props: { onComplete: (slug?: string) => void; onCancel: () => void; initialUrl?: string; initialPreset?: string; initialNamespace?: string;}) { const isGoogleBundlePreset = props.initialPreset === GOOGLE_BUNDLE_PRESET_ID; // Spec input. For the Google BUNDLE preset the input is a product picker (a set // of selected Discovery URLs), not a single spec URL/blob — the merge happens // server-side via `{ kind: "googleDiscoveryBundle", urls }`, so the textarea // preview path is bypassed entirely. const [specUrl, setSpecUrl] = useState(props.initialUrl ?? ""); const [selectedPresetIds, setSelectedPresetIds] = useState<ReadonlySet<string>>( googleBundleDefaultPresetIds, ); const [customDiscoveryUrls, setCustomDiscoveryUrls] = useState<readonly string[]>([]); const [analyzing, setAnalyzing] = useState(false); const [analyzeError, setAnalyzeError] = useState<string | null>(null); // After analysis const [preview, setPreview] = useState<SpecPreview | null>(null); const [baseUrl, setBaseUrl] = useState(isGoogleBundlePreset ? GOOGLE_BUNDLE_BASE_URL : ""); const identityFallbackName = isGoogleBundlePreset ? "Google" : preview ? Option.getOrElse(preview.title, () => "") : ""; const identity = useIntegrationIdentity({ fallbackName: identityFallbackName, fallbackNamespace: props.initialNamespace ?? (isGoogleBundlePreset ? "google" : undefined), }); const bundleDiscoveryUrls = useMemo( () => googleBundleUrls(selectedPresetIds, customDiscoveryUrls), [selectedPresetIds, customDiscoveryUrls], ); const toggleBundlePreset = useCallback((presetId: string, checked: boolean) => { setSelectedPresetIds((current: ReadonlySet<string>) => { const next = new Set(current); if (checked) next.add(presetId); else next.delete(presetId); return next; }); }, []); const addCustomDiscoveryUrl = useCallback((url: string) => { setCustomDiscoveryUrls((current: readonly string[]) => current.includes(url) ? current : [...current, url], ); }, []); const removeCustomDiscoveryUrl = useCallback((url: string) => { setCustomDiscoveryUrls((current: readonly string[]) => current.filter((entry: string) => entry !== url), ); }, []); // Submit const [adding, setAdding] = useState(false); const [addError, setAddError] = useState<string | null>(null); const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" }); const doAdd = useAtomSet(addOpenApiSpec, { mode: "promiseExit" }); // 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>(() => {}); useEffect(() => { // The bundle preset never analyzes a single spec — its input is the picker. if (isGoogleBundlePreset) return; const trimmed = specUrl.trim(); if (!trimmed) return; if (preview) return; const handle = setTimeout(() => { handleAnalyzeRef.current(); }, 400); return () => clearTimeout(handle); }, [specUrl, preview, isGoogleBundlePreset]); // ---- Derived state ---- const servers: readonly ServerInfo[] = preview?.servers ?? []; const baseUrlOptions = Array.from( new Map(servers.flatMap(expandServerOptions).map((option) => [option.value, option])).values(), ); const previewPresetIcon = openApiPresets.find( (preset) => preset.url && normalizePresetUrl(preset.url) === normalizePresetUrl(specUrl), )?.icon ?? null; const resolvedBaseUrl = baseUrl.trim(); const resolvedSourceId = slugifyNamespace(identity.namespace) || (preview ? Option.getOrElse(preview.title, () => "openapi") : "openapi"); const resolvedDisplayName = identity.name.trim() || (preview ? Option.getOrElse(preview.title, () => resolvedSourceId) : resolvedSourceId); // Register EVERY spec-detected auth method, not just a single selected one. // Keyed off `preview` (stable per analysis) so the memo doesn't re-run on the // freshly-allocated `?? []` fallback arrays. const authenticationTemplate: readonly Authentication[] = useMemo( () => detectedAuthenticationTemplates( preview?.headerPresets ?? [], preview?.oauth2Presets ?? [], resolvedBaseUrl, ), [preview, resolvedBaseUrl], ); const detectedMethodLabels: readonly string[] = useMemo( () => [ ...(preview?.headerPresets ?? []).map((preset) => preset.label), ...(preview?.oauth2Presets ?? []).map((preset) => preset.label), ], [preview], ); // Editable auth methods, seeded from the spec-detected templates. The add flow // registers EVERY method (P6) — so this is a LIST, preserving multi-method // specs (e.g. apiKey + OAuth). Each row carries its editor value plus the // detected template's original `seedSlug`, so an unedited detected method // submits with its EXACT original slug (preserving behavior); added methods // (no seed) get a deterministic fresh slug. The user can edit, add, or remove // a method before submitting; on submit the list converts back to // `Authentication[]`. Re-seeded whenever a fresh detection arrives (keyed on // the detected templates, which are stable per analysis + base URL). type AuthMethodRow = { readonly value: AuthTemplateEditorValue; readonly seedSlug?: string; }; const [authMethods, setAuthMethods] = useState<readonly AuthMethodRow[]>([]); const seededFromRef = useRef<readonly Authentication[] | null>(null); useEffect(() => { if (seededFromRef.current === authenticationTemplate) return; seededFromRef.current = authenticationTemplate; setAuthMethods( authenticationTemplate.map((template: Authentication) => ({ value: editorValueFromAuthentication(template), seedSlug: String(template.slug), })), ); }, [authenticationTemplate]); const setAuthMethodAt = useCallback((index: number, next: AuthTemplateEditorValue) => { setAuthMethods((current: readonly AuthMethodRow[]) => current.map((row: AuthMethodRow, i: number) => (i === index ? { ...row, value: next } : row)), ); }, []); const removeAuthMethodAt = useCallback((index: number) => { setAuthMethods((current: readonly AuthMethodRow[]) => current.filter((_row: AuthMethodRow, i: number) => i !== index), ); }, []); const addAuthMethod = useCallback(() => { setAuthMethods((current: readonly AuthMethodRow[]) => [ ...current, { value: { kind: "apikey", placements: [{ carrier: "header", name: "Authorization", prefix: "" }], }, }, ]); }, []); // The methods to register, mapped back to stored `Authentication[]`. Drops // `none` rows (nothing to register). An unedited detected method keeps its // original `seedSlug`; an added method gets a deterministic fresh slug. const editedAuthenticationTemplate: readonly Authentication[] = useMemo(() => { const templates: Authentication[] = []; authMethods.forEach((row: AuthMethodRow, index: number) => { const slug = row.seedSlug ?? (row.value.kind === "oauth" ? `oauth-${index}` : `apikey-${index}`); const template = authenticationFromEditorValue(row.value, slug); if (template !== null) templates.push(template); }); return templates; }, [authMethods]); // Pre-empt the API's `IntegrationAlreadyExistsError`: adding an integration // whose slug already exists clobbers the existing one's connections/policies, // so the API blocks it. Surface that here from the tenant-scoped catalog list. const integrationsResult = useAtomValue(integrationsOptimisticAtom); const slugAlreadyExists = useMemo( () => AsyncResult.isSuccess(integrationsResult) && integrationsResult.value.some((integration) => integration.slug === resolvedSourceId), [integrationsResult, resolvedSourceId], ); // The bundle path is ready once at least one Google API is selected (no // network preview gates it); the single/custom-spec path still requires a // successful preview. Both require a base URL and a free slug. const hasPreviewOrBundle = isGoogleBundlePreset ? bundleDiscoveryUrls.length > 0 : preview !== null; const canAdd = hasPreviewOrBundle && resolvedBaseUrl.length > 0 && !slugAlreadyExists; // ---- Handlers ---- const handleAnalyze = async () => { setAnalyzing(true); setAnalyzeError(null); setAddError(null); const exit = await doPreview({ payload: { spec: specUrl } }); if (Exit.isFailure(exit)) { setAnalyzeError(errorMessageFromExit(exit, "Failed to parse spec")); setAnalyzing(false); return; } const result = exit.value; setPreview(result); setBaseUrl(firstBaseUrlForPreview(result)); setAnalyzing(false); }; handleAnalyzeRef.current = handleAnalyze; // Persist the integration and return its slug. Registers the catalog entry // with every detected auth method. Adding a slug that already exists is // rejected by the API (`IntegrationAlreadyExistsError`) — surfaced inline. const ensureIntegration = useCallback(async (): Promise<IntegrationSlug | null> => { // The Google BUNDLE preset emits the multi-service bundle input; the server // merges the selected Discovery documents into one `google` spec and stores // the unioned `googleOAuth2` auth template (so no client template is sent). // Every other preset/custom input keeps the single-spec url/blob/discovery // branch unchanged. const specForAdd = isGoogleBundlePresetLines 1–500 of 740. Load more
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAtomSet, useAtomValue } from "@effect/atom-react"; import { Link } from "@tanstack/react-router"; import * as Effect from "effect/Effect"; import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; import * as Predicate from "effect/Predicate"; import * as Schema from "effect/Schema"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; import { AuthTemplateSlug, IntegrationSlug, type OAuthAuthentication, } from "@executor-js/sdk/shared"; import { integrationsOptimisticAtom } from "@executor-js/react/api/atoms"; import { integrationWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { slugifyNamespace, useIntegrationIdentity, } from "@executor-js/react/plugins/integration-identity"; import { Button } from "@executor-js/react/components/button"; import { AuthTemplateEditor, type AuthTemplateEditorValue, } from "@executor-js/react/components/auth-template-editor"; import { CardStack, CardStackContent } from "@executor-js/react/components/card-stack"; import { FieldLabel } from "@executor-js/react/components/field"; import { FloatActions } from "@executor-js/react/components/float-actions"; import { Textarea } from "@executor-js/react/components/textarea"; import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; import { PlusIcon, XIcon } from "lucide-react"; import { authenticationFromEditorValue, editorValueFromAuthentication } from "./auth-method-config"; import { addOpenApiSpec, previewOpenApiSpec } from "./atoms"; import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields"; import { GoogleProductPicker } from "./GoogleProductPicker"; import { openApiPresets } from "../sdk/presets"; import { GOOGLE_BUNDLE_PRESET_ID, googleOpenApiPresets, type GoogleOpenApiPreset, } from "../sdk/google-presets"; import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview"; import { type APIKeyAuthentication, type Authentication, type ServerInfo, TOKEN_VARIABLE, variable, } from "../sdk/types"; import { expandServerUrlOptions } from "../sdk/openapi-utils"; const GOOGLE_BUNDLE_BASE_URL = "https://www.googleapis.com/"; const GOOGLE_BUNDLE_FAVICON = "https://fonts.gstatic.com/s/i/productlogos/googleg/v6/192px.svg"; // The bundle picker opens with the featured Google APIs pre-checked. const googleBundleDefaultPresetIds: ReadonlySet<string> = new Set( googleOpenApiPresets .filter((preset: GoogleOpenApiPreset) => preset.featured) .map((preset: GoogleOpenApiPreset) => preset.id), ); const googleBundleUrls = ( selectedPresetIds: ReadonlySet<string>, customUrls: readonly string[], ): readonly string[] => { const fromPresets = googleOpenApiPresets.flatMap((preset: GoogleOpenApiPreset) => preset.url && selectedPresetIds.has(preset.id) ? [preset.url] : [], ); // Preset URLs first (stable order), then any custom Discovery URLs, de-duped. return [...new Set([...fromPresets, ...customUrls])]; }; 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, }); const isIntegrationAlreadyExistsExit = (exit: Exit.Exit<unknown, unknown>): boolean => Option.match(Exit.findErrorOption(exit), { onNone: () => false, onSome: Predicate.isTagged("IntegrationAlreadyExistsError"), }); const integrationExistsMessage = (slug: string): string => `An integration named "${slug}" already exists. To add more authentication, update your existing integration.`; // --------------------------------------------------------------------------- // OpenAPI url helpers — specs sometimes ship relative OAuth endpoints; resolve // them against the chosen base URL so the stored auth template is absolute. // --------------------------------------------------------------------------- export function resolveOAuthUrl(url: string, baseUrl: string): string { if (!url) return url; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor normalizes provider metadata URLs try { new URL(url); return url; } catch { if (!baseUrl) return url; // oxlint-disable-next-line executor/no-try-catch-or-throw -- boundary: URL constructor resolves relative provider metadata URLs try { return new URL(url, baseUrl).toString(); } catch { return url; } } } const standardOidcIdentityScopes = ["openid", "email", "profile"] as const; const identityScopesForPreset = ( identityScopes: OAuth2Preset["identityScopes"], ): readonly string[] => { if (identityScopes === false) return []; return identityScopes === "auto" ? standardOidcIdentityScopes : identityScopes; }; const resolvedOAuthScopes = ( apiScopes: Iterable<string>, identityScopes: OAuth2Preset["identityScopes"], ): string[] => { const merged = new Set(apiScopes); for (const scope of identityScopesForPreset(identityScopes)) merged.add(scope); return [...merged]; }; const isGoogleDiscoveryUrl = (url: string): boolean => { const trimmed = url.trim(); if (!URL.canParse(trimmed)) return false; const parsed = new URL(trimmed); const host = parsed.hostname.toLowerCase(); if (!host.endsWith("googleapis.com")) return false; return parsed.pathname.includes("/discovery/") || parsed.pathname.includes("$discovery"); }; const normalizePresetUrl = (url: string): string => { const trimmed = url.trim(); if (!URL.canParse(trimmed)) return trimmed.replace(/\/$/, ""); const parsed = new URL(trimmed); parsed.hash = ""; parsed.searchParams.sort(); return parsed.toString().replace(/\/$/, ""); }; const specInputForAdd = (input: string) => { const value = input.trim(); const parsed = Effect.runSyncExit( Effect.try({ try: () => new URL(value), catch: () => null, }), ); return Exit.isSuccess(parsed) ? isGoogleDiscoveryUrl(value) ? { kind: "googleDiscovery" as const, url: value } : { kind: "url" as const, url: value } : { kind: "blob" as const, value }; }; // --------------------------------------------------------------------------- // Auth-template builders — turn a preview preset into the integration's stored // `Authentication` template (v2). The header preset becomes an `apiKey` template // whose secret header value renders the resolved credential via `variable(token)`; // the oauth2 preset becomes an `oauth` template carrying the provider endpoints. // // Post-redesign the add flow no longer asks the user to pick ONE method: every // spec-detected method is registered so the integration's detail hub can list // them and Add-account can choose among them (P6: add without auth, connect // later). // --------------------------------------------------------------------------- const headerPrefix = (preset: HeaderPreset, headerName: string): string | undefined => { const label = preset.label.toLowerCase(); if (headerName.toLowerCase() === "authorization") { if (label.includes("bearer")) return "Bearer "; if (label.includes("basic")) return "Basic "; } return undefined; }; const apiKeyTemplateFromHeaderPreset = ( preset: HeaderPreset, slug: AuthTemplateSlug, ): APIKeyAuthentication => { const headers: Record<string, (string | ReturnType<typeof variable>)[]> = {}; for (const headerName of preset.secretHeaders) { const prefix = headerPrefix(preset, headerName); headers[headerName] = prefix ? [prefix, variable(TOKEN_VARIABLE)] : [variable(TOKEN_VARIABLE)]; } return { slug, type: "apiKey", headers }; }; const oauthTemplateFromPreset = ( preset: OAuth2Preset, baseUrl: string, slug: AuthTemplateSlug, scopes: readonly string[], ): OAuthAuthentication => ({ slug, type: "oauth", authorizationUrl: resolveOAuthUrl( Option.getOrElse(preset.authorizationUrl, () => ""), baseUrl, ), tokenUrl: resolveOAuthUrl(preset.tokenUrl, baseUrl), scopes: [...scopes], }); const expandServerOptions = (server: ServerInfo) => expandServerUrlOptions(server).map((value) => ({ value, label: value })); const firstBaseUrlForPreview = (preview: SpecPreview): string => { const firstServer = preview.servers[0]; return firstServer ? (expandServerUrlOptions(firstServer)[0] ?? "") : ""; }; // --------------------------------------------------------------------------- // All spec-detected auth methods → the union of stored `Authentication` // templates. Header presets become apiKey templates; each oauth2 preset becomes // an oauth template (with its declared API scopes plus, for auth-code flows, // the standard identity scopes). Slugs stay deterministic per method so the // stored template is stable across previews of the same spec. Adding an // integration whose slug already exists is blocked (see the existing-slug // guard below); to add more auth, update the existing integration instead. // --------------------------------------------------------------------------- const detectedAuthenticationTemplates = ( headerPresets: readonly HeaderPreset[], oauth2Presets: readonly OAuth2Preset[], baseUrl: string, ): readonly Authentication[] => { const templates: Authentication[] = []; headerPresets.forEach((preset, index) => { templates.push( apiKeyTemplateFromHeaderPreset(preset, AuthTemplateSlug.make(`apikey-${index}`)), ); }); for (const preset of oauth2Presets) { const scopes = resolvedOAuthScopes(Object.keys(preset.scopes), preset.identityScopes); templates.push( oauthTemplateFromPreset( preset, baseUrl, AuthTemplateSlug.make(`oauth-${preset.securitySchemeName}`), scopes, ), ); } return templates; }; // --------------------------------------------------------------------------- // Component — single progressive form. Post-redesign: preview → addSpec // (register the integration catalog entry with ALL detected auth methods) → // route to the integration's detail hub, where the user adds accounts. The add // flow no longer creates a connection. // --------------------------------------------------------------------------- export default function AddOpenApiSource(props: { onComplete: (slug?: string) => void; onCancel: () => void; initialUrl?: string; initialPreset?: string; initialNamespace?: string; }) { const isGoogleBundlePreset = props.initialPreset === GOOGLE_BUNDLE_PRESET_ID; // Spec input. For the Google BUNDLE preset the input is a product picker (a set // of selected Discovery URLs), not a single spec URL/blob — the merge happens // server-side via `{ kind: "googleDiscoveryBundle", urls }`, so the textarea // preview path is bypassed entirely. const [specUrl, setSpecUrl] = useState(props.initialUrl ?? ""); const [selectedPresetIds, setSelectedPresetIds] = useState<ReadonlySet<string>>( googleBundleDefaultPresetIds, ); const [customDiscoveryUrls, setCustomDiscoveryUrls] = useState<readonly string[]>([]); const [analyzing, setAnalyzing] = useState(false); const [analyzeError, setAnalyzeError] = useState<string | null>(null); // After analysis const [preview, setPreview] = useState<SpecPreview | null>(null); const [baseUrl, setBaseUrl] = useState(isGoogleBundlePreset ? GOOGLE_BUNDLE_BASE_URL : ""); const identityFallbackName = isGoogleBundlePreset ? "Google" : preview ? Option.getOrElse(preview.title, () => "") : ""; const identity = useIntegrationIdentity({ fallbackName: identityFallbackName, fallbackNamespace: props.initialNamespace ?? (isGoogleBundlePreset ? "google" : undefined), }); const bundleDiscoveryUrls = useMemo( () => googleBundleUrls(selectedPresetIds, customDiscoveryUrls), [selectedPresetIds, customDiscoveryUrls], ); const toggleBundlePreset = useCallback((presetId: string, checked: boolean) => { setSelectedPresetIds((current: ReadonlySet<string>) => { const next = new Set(current); if (checked) next.add(presetId); else next.delete(presetId); return next; }); }, []); const addCustomDiscoveryUrl = useCallback((url: string) => { setCustomDiscoveryUrls((current: readonly string[]) => current.includes(url) ? current : [...current, url], ); }, []); const removeCustomDiscoveryUrl = useCallback((url: string) => { setCustomDiscoveryUrls((current: readonly string[]) => current.filter((entry: string) => entry !== url), ); }, []); // Submit const [adding, setAdding] = useState(false); const [addError, setAddError] = useState<string | null>(null); const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promiseExit" }); const doAdd = useAtomSet(addOpenApiSpec, { mode: "promiseExit" }); // 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>(() => {}); useEffect(() => { // The bundle preset never analyzes a single spec — its input is the picker. if (isGoogleBundlePreset) return; const trimmed = specUrl.trim(); if (!trimmed) return; if (preview) return; const handle = setTimeout(() => { handleAnalyzeRef.current(); }, 400); return () => clearTimeout(handle); }, [specUrl, preview, isGoogleBundlePreset]); // ---- Derived state ---- const servers: readonly ServerInfo[] = preview?.servers ?? []; const baseUrlOptions = Array.from( new Map(servers.flatMap(expandServerOptions).map((option) => [option.value, option])).values(), ); const previewPresetIcon = openApiPresets.find( (preset) => preset.url && normalizePresetUrl(preset.url) === normalizePresetUrl(specUrl), )?.icon ?? null; const resolvedBaseUrl = baseUrl.trim(); const resolvedSourceId = slugifyNamespace(identity.namespace) || (preview ? Option.getOrElse(preview.title, () => "openapi") : "openapi"); const resolvedDisplayName = identity.name.trim() || (preview ? Option.getOrElse(preview.title, () => resolvedSourceId) : resolvedSourceId); // Register EVERY spec-detected auth method, not just a single selected one. // Keyed off `preview` (stable per analysis) so the memo doesn't re-run on the // freshly-allocated `?? []` fallback arrays. const authenticationTemplate: readonly Authentication[] = useMemo( () => detectedAuthenticationTemplates( preview?.headerPresets ?? [], preview?.oauth2Presets ?? [], resolvedBaseUrl, ), [preview, resolvedBaseUrl], ); const detectedMethodLabels: readonly string[] = useMemo( () => [ ...(preview?.headerPresets ?? []).map((preset) => preset.label), ...(preview?.oauth2Presets ?? []).map((preset) => preset.label), ], [preview], ); // Editable auth methods, seeded from the spec-detected templates. The add flow // registers EVERY method (P6) — so this is a LIST, preserving multi-method // specs (e.g. apiKey + OAuth). Each row carries its editor value plus the // detected template's original `seedSlug`, so an unedited detected method // submits with its EXACT original slug (preserving behavior); added methods // (no seed) get a deterministic fresh slug. The user can edit, add, or remove // a method before submitting; on submit the list converts back to // `Authentication[]`. Re-seeded whenever a fresh detection arrives (keyed on // the detected templates, which are stable per analysis + base URL). type AuthMethodRow = { readonly value: AuthTemplateEditorValue; readonly seedSlug?: string; }; const [authMethods, setAuthMethods] = useState<readonly AuthMethodRow[]>([]); const seededFromRef = useRef<readonly Authentication[] | null>(null); useEffect(() => { if (seededFromRef.current === authenticationTemplate) return; seededFromRef.current = authenticationTemplate; setAuthMethods( authenticationTemplate.map((template: Authentication) => ({ value: editorValueFromAuthentication(template), seedSlug: String(template.slug), })), ); }, [authenticationTemplate]); const setAuthMethodAt = useCallback((index: number, next: AuthTemplateEditorValue) => { setAuthMethods((current: readonly AuthMethodRow[]) => current.map((row: AuthMethodRow, i: number) => (i === index ? { ...row, value: next } : row)), ); }, []); const removeAuthMethodAt = useCallback((index: number) => { setAuthMethods((current: readonly AuthMethodRow[]) => current.filter((_row: AuthMethodRow, i: number) => i !== index), ); }, []); const addAuthMethod = useCallback(() => { setAuthMethods((current: readonly AuthMethodRow[]) => [ ...current, { value: { kind: "apikey", placements: [{ carrier: "header", name: "Authorization", prefix: "" }], }, }, ]); }, []); // The methods to register, mapped back to stored `Authentication[]`. Drops // `none` rows (nothing to register). An unedited detected method keeps its // original `seedSlug`; an added method gets a deterministic fresh slug. const editedAuthenticationTemplate: readonly Authentication[] = useMemo(() => { const templates: Authentication[] = []; authMethods.forEach((row: AuthMethodRow, index: number) => { const slug = row.seedSlug ?? (row.value.kind === "oauth" ? `oauth-${index}` : `apikey-${index}`); const template = authenticationFromEditorValue(row.value, slug); if (template !== null) templates.push(template); }); return templates; }, [authMethods]); // Pre-empt the API's `IntegrationAlreadyExistsError`: adding an integration // whose slug already exists clobbers the existing one's connections/policies, // so the API blocks it. Surface that here from the tenant-scoped catalog list. const integrationsResult = useAtomValue(integrationsOptimisticAtom); const slugAlreadyExists = useMemo( () => AsyncResult.isSuccess(integrationsResult) && integrationsResult.value.some((integration) => integration.slug === resolvedSourceId), [integrationsResult, resolvedSourceId], ); // The bundle path is ready once at least one Google API is selected (no // network preview gates it); the single/custom-spec path still requires a // successful preview. Both require a base URL and a free slug. const hasPreviewOrBundle = isGoogleBundlePreset ? bundleDiscoveryUrls.length > 0 : preview !== null; const canAdd = hasPreviewOrBundle && resolvedBaseUrl.length > 0 && !slugAlreadyExists; // ---- Handlers ---- const handleAnalyze = async () => { setAnalyzing(true); setAnalyzeError(null); setAddError(null); const exit = await doPreview({ payload: { spec: specUrl } }); if (Exit.isFailure(exit)) { setAnalyzeError(errorMessageFromExit(exit, "Failed to parse spec")); setAnalyzing(false); return; } const result = exit.value; setPreview(result); setBaseUrl(firstBaseUrlForPreview(result)); setAnalyzing(false); }; handleAnalyzeRef.current = handleAnalyze; // Persist the integration and return its slug. Registers the catalog entry // with every detected auth method. Adding a slug that already exists is // rejected by the API (`IntegrationAlreadyExistsError`) — surfaced inline. const ensureIntegration = useCallback(async (): Promise<IntegrationSlug | null> => { // The Google BUNDLE preset emits the multi-service bundle input; the server // merges the selected Discovery documents into one `google` spec and stores // the unioned `googleOAuth2` auth template (so no client template is sent). // Every other preset/custom input keeps the single-spec url/blob/discovery // branch unchanged. const specForAdd = isGoogleBundlePreset