import { startTransition, useMemo, useState, type ReactNode } from "react"; import type { Source } from "@executor/react"; import { SecretReferenceField, defineExecutorPluginHttpApiClient, Result, useAtomSet, useAtomValue, useExecutorMutation, useLocalInstallation, useSource, } from "@executor/react"; import { Alert, Badge, Button, IconPencil, Input, Label, Select, Separator, SourceToolExplorer, Textarea, parseSourceToolExplorerSearch, type SourceToolExplorerSearch, useSourcePluginNavigation, useSourcePluginRouteParams, useSourcePluginSearch, } from "@executor/react/plugins"; import { googleDiscoveryHttpApiExtension, } from "@executor/plugin-google-discovery-http"; import { GOOGLE_DISCOVERY_OAUTH_CALLBACK_PATH, GOOGLE_DISCOVERY_OAUTH_STORAGE_PREFIX, GOOGLE_DISCOVERY_PLUGIN_KEY, GOOGLE_DISCOVERY_SOURCE_KIND, defaultGoogleDiscoveryUrl, type GoogleDiscoveryBatchSourceInput, type GoogleDiscoveryConnectInput, type GoogleDiscoveryConnectionAuth, type GoogleDiscoveryOAuthPopupResult, type GoogleDiscoveryStartBatchOAuthInput, type GoogleDiscoveryStartOAuthInput, } from "@executor/plugin-google-discovery-shared"; import { getGoogleDiscoveryIconUrlForService } from "./icons"; const OAUTH_TIMEOUT_MS = 2 * 60_000; const getGoogleDiscoveryHttpClient = defineExecutorPluginHttpApiClient<"GoogleDiscoveryReactHttpClient">()( "GoogleDiscoveryReactHttpClient", [googleDiscoveryHttpApiExtension] as const, ); const defaultGoogleInput = (): GoogleDiscoveryConnectInput => ({ name: "Google Sheets", service: "sheets", version: "v4", discoveryUrl: defaultGoogleDiscoveryUrl("sheets", "v4"), defaultHeaders: null, scopes: [], auth: { kind: "none", }, }); type GoogleDiscoveryBatchTemplate = { id: string; name: string; summary: string; service: string; version: string; discoveryUrl: string; }; const googleDiscoveryTemplate = ( input: GoogleDiscoveryBatchTemplate, ): GoogleDiscoveryBatchTemplate => ({ ...input, discoveryUrl: input.discoveryUrl || defaultGoogleDiscoveryUrl(input.service, input.version), }); const GOOGLE_DISCOVERY_BATCH_TEMPLATES: ReadonlyArray = [ googleDiscoveryTemplate({ id: "google-calendar", name: "Google Calendar", summary: "Calendars, events, ACLs, and scheduling workflows.", service: "calendar", version: "v3", discoveryUrl: "https://calendar-json.googleapis.com/$discovery/rest?version=v3", }), googleDiscoveryTemplate({ id: "google-drive", name: "Google Drive", summary: "Files, folders, permissions, comments, and shared drives.", service: "drive", version: "v3", discoveryUrl: defaultGoogleDiscoveryUrl("drive", "v3"), }), googleDiscoveryTemplate({ id: "google-gmail", name: "Gmail", summary: "Messages, threads, labels, drafts, and mailbox automation.", service: "gmail", version: "v1", discoveryUrl: "https://gmail.googleapis.com/$discovery/rest?version=v1", }), googleDiscoveryTemplate({ id: "google-docs", name: "Google Docs", summary: "Documents, structural edits, text ranges, and formatting.", service: "docs", version: "v1", discoveryUrl: "https://docs.googleapis.com/$discovery/rest?version=v1", }), googleDiscoveryTemplate({ id: "google-sheets", name: "Google Sheets", summary: "Spreadsheets, values, ranges, formatting, and batch updates.", service: "sheets", version: "v4", discoveryUrl: "https://sheets.googleapis.com/$discovery/rest?version=v4", }), googleDiscoveryTemplate({ id: "google-slides", name: "Google Slides", summary: "Presentations, slides, page elements, and deck updates.", service: "slides", version: "v1", discoveryUrl: "https://slides.googleapis.com/$discovery/rest?version=v1", }), googleDiscoveryTemplate({ id: "google-forms", name: "Google Forms", summary: "Forms, questions, responses, quizzes, and form metadata.", service: "forms", version: "v1", discoveryUrl: "https://forms.googleapis.com/$discovery/rest?version=v1", }), googleDiscoveryTemplate({ id: "google-search-console", name: "Google Search Console", summary: "Sites, sitemaps, URL inspection, and search performance.", service: "searchconsole", version: "v1", discoveryUrl: "https://searchconsole.googleapis.com/$discovery/rest?version=v1", }), googleDiscoveryTemplate({ id: "google-people", name: "Google People", summary: "Contacts, profiles, directory people, and contact groups.", service: "people", version: "v1", discoveryUrl: "https://people.googleapis.com/$discovery/rest?version=v1", }), googleDiscoveryTemplate({ id: "google-tasks", name: "Google Tasks", summary: "Task lists, task items, notes, and due dates.", service: "tasks", version: "v1", discoveryUrl: "https://tasks.googleapis.com/$discovery/rest?version=v1", }), googleDiscoveryTemplate({ id: "google-chat", name: "Google Chat", summary: "Spaces, messages, members, reactions, and chat workflows.", service: "chat", version: "v1", discoveryUrl: "https://chat.googleapis.com/$discovery/rest?version=v1", }), googleDiscoveryTemplate({ id: "google-keep", name: "Google Keep", summary: "Notes, lists, attachments, and collaborative annotations.", service: "keep", version: "v1", discoveryUrl: "https://keep.googleapis.com/$discovery/rest?version=v1", }), googleDiscoveryTemplate({ id: "google-classroom", name: "Google Classroom", summary: "Courses, rosters, coursework, submissions, and grading data.", service: "classroom", version: "v1", discoveryUrl: "https://classroom.googleapis.com/$discovery/rest?version=v1", }), googleDiscoveryTemplate({ id: "google-admin-directory", name: "Google Admin Directory", summary: "Users, groups, org units, roles, and directory resources.", service: "admin", version: "directory_v1", discoveryUrl: "https://admin.googleapis.com/$discovery/rest?version=directory_v1", }), googleDiscoveryTemplate({ id: "google-admin-reports", name: "Google Admin Reports", summary: "Audit events, usage reports, and admin activity logs.", service: "admin", version: "reports_v1", discoveryUrl: "https://admin.googleapis.com/$discovery/rest?version=reports_v1", }), googleDiscoveryTemplate({ id: "google-apps-script", name: "Google Apps Script", summary: "Projects, deployments, execution, and script metadata.", service: "script", version: "v1", discoveryUrl: "https://script.googleapis.com/$discovery/rest?version=v1", }), googleDiscoveryTemplate({ id: "google-bigquery", name: "Google BigQuery", summary: "Datasets, tables, jobs, routines, and analytics workflows.", service: "bigquery", version: "v2", discoveryUrl: "https://bigquery.googleapis.com/$discovery/rest?version=v2", }), googleDiscoveryTemplate({ id: "google-cloud-resource-manager", name: "Google Cloud Resource Manager", summary: "Projects, folders, organizations, and IAM hierarchy.", service: "cloudresourcemanager", version: "v3", discoveryUrl: "https://cloudresourcemanager.googleapis.com/$discovery/rest?version=v3", }), googleDiscoveryTemplate({ id: "google-youtube-data", name: "YouTube Data", summary: "Channels, playlists, videos, comments, captions, and uploads.", service: "youtube", version: "v3", discoveryUrl: "https://youtube.googleapis.com/$discovery/rest?version=v3", }), ]; const presetString = ( search: Record, key: string, ): string | null => { const value = search[key]; return typeof value === "string" && value.trim().length > 0 ? value.trim() : null; }; const parsedGoogleDiscoveryInputFromUrl = ( value: string | null, ): Pick | null => { if (!value) { return null; } try { const url = new URL(value); const byDirectory = url.pathname.match(/^\/discovery\/v1\/apis\/([^/]+)\/([^/]+)\/rest$/); if (byDirectory) { const service = decodeURIComponent(byDirectory[1] ?? ""); const version = decodeURIComponent(byDirectory[2] ?? ""); const knownTemplate = GOOGLE_DISCOVERY_BATCH_TEMPLATES.find( (entry) => entry.service === service && entry.version === version, ); return { name: knownTemplate?.name ?? `Google ${service}`, service, version, discoveryUrl: url.toString(), }; } const version = url.searchParams.get("version")?.trim() ?? ""; const isHostScopedDiscovery = url.pathname === "/$discovery/rest" && url.hostname.endsWith(".googleapis.com") && url.hostname !== "www.googleapis.com"; if (version && isHostScopedDiscovery) { const service = url.hostname.split(".")[0] ?? ""; const knownTemplate = GOOGLE_DISCOVERY_BATCH_TEMPLATES.find( (entry) => entry.service === service && entry.version === version, ); return { name: knownTemplate?.name ?? `Google ${service}`, service, version, discoveryUrl: url.toString(), }; } } catch {} return null; }; const googleInputFromSearch = ( search: Record, ): GoogleDiscoveryConnectInput => { const defaults = defaultGoogleInput(); const parsedFromInputUrl = parsedGoogleDiscoveryInputFromUrl( presetString(search, "inputUrl") ?? presetString(search, "input") ?? presetString(search, "url"), ); const service = presetString(search, "presetService") ?? parsedFromInputUrl?.service ?? defaults.service; const version = presetString(search, "presetVersion") ?? parsedFromInputUrl?.version ?? defaults.version; return { ...defaults, name: presetString(search, "presetName") ?? parsedFromInputUrl?.name ?? defaults.name, service, version, discoveryUrl: presetString(search, "presetDiscoveryUrl") ?? parsedFromInputUrl?.discoveryUrl ?? defaultGoogleDiscoveryUrl(service, version), }; }; const stringifyStringMap = ( value: Record | null | undefined, ): string => !value || Object.keys(value).length === 0 ? "" : JSON.stringify(value, null, 2); const parseStringMap = (value: string): Record | null => { const trimmed = value.trim(); if (!trimmed) { return null; } const parsed = JSON.parse(trimmed) as unknown; if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { throw new Error("Headers must be a JSON object."); } const entries = Object.entries(parsed as Record); if (!entries.every(([, entry]) => typeof entry === "string")) { throw new Error("All header values must be strings."); } return Object.fromEntries(entries as Array<[string, string]>); }; const stringifyScopes = (value: ReadonlyArray): string => value.join("\n"); const parseScopes = (value: string): Array => value .split(/[\n,]/) .map((entry) => entry.trim()) .filter(Boolean); const authSecretValue = ( input: GoogleDiscoveryConnectionAuth, ): string => input.kind === "bearer" ? JSON.stringify(input.tokenSecretRef) : ""; const clientSecretValue = ( input: GoogleDiscoveryConnectionAuth, ): string => input.kind === "oauth2" && input.clientSecretRef ? JSON.stringify(input.clientSecretRef) : ""; const waitForOauthPopupResult = async ( sessionId: string, ): Promise => new Promise((resolve, reject) => { const storageKey = `${GOOGLE_DISCOVERY_OAUTH_STORAGE_PREFIX}${sessionId}`; const startedAt = Date.now(); const cleanup = () => { window.removeEventListener("message", handleMessage); window.clearInterval(intervalId); }; const finish = (result: GoogleDiscoveryOAuthPopupResult) => { cleanup(); try { window.localStorage.removeItem(storageKey); } catch {} resolve(result); }; const handleMessage = (event: MessageEvent) => { if (event.origin !== window.location.origin) { return; } const data = event.data as GoogleDiscoveryOAuthPopupResult | undefined; if (!data || data.type !== "executor:oauth-result") { return; } if (data.ok && data.sessionId !== sessionId) { return; } finish(data); }; window.addEventListener("message", handleMessage); const intervalId = window.setInterval(() => { if (Date.now() - startedAt > OAUTH_TIMEOUT_MS) { cleanup(); reject(new Error("Timed out waiting for Google OAuth to finish.")); return; } try { const raw = window.localStorage.getItem(storageKey); if (!raw) { return; } finish(JSON.parse(raw) as GoogleDiscoveryOAuthPopupResult); } catch { // Ignore malformed local storage and continue polling. } }, 400); }); const openGoogleOauthPopup = (name: string): Window | null => { const popup = window.open("", name, "width=560,height=760"); if (!popup) { return null; } try { popup.document.title = "Sign in with Google"; popup.document.body.innerHTML = `

Redirecting to Google…

`; } catch { // Ignore browsers that don't allow writing to the popup document here. } return popup; }; const batchSourceInputFromTemplate = ( template: GoogleDiscoveryBatchTemplate, ): GoogleDiscoveryBatchSourceInput => ({ name: template.name, service: template.service, version: template.version, discoveryUrl: template.discoveryUrl, defaultHeaders: null, scopes: [], }); function GoogleDiscoverySourceForm(props: { initialValue: GoogleDiscoveryConnectInput; mode: "create" | "edit"; onSubmit: (input: GoogleDiscoveryConnectInput) => Promise; }) { const installation = useLocalInstallation(); const client = getGoogleDiscoveryHttpClient(); const startOAuth = useAtomSet( client.mutation(GOOGLE_DISCOVERY_PLUGIN_KEY, "startOAuth"), { mode: "promise" }, ); const submitMutation = useExecutorMutation(props.onSubmit); const [name, setName] = useState(props.initialValue.name); const [service, setService] = useState(props.initialValue.service); const [version, setVersion] = useState(props.initialValue.version); const [discoveryUrl, setDiscoveryUrl] = useState(props.initialValue.discoveryUrl ?? ""); const [headersText, setHeadersText] = useState( stringifyStringMap(props.initialValue.defaultHeaders), ); const [scopesText, setScopesText] = useState( stringifyScopes(props.initialValue.scopes), ); const [authKind, setAuthKind] = useState( props.initialValue.auth.kind, ); const [bearerSecretRef, setBearerSecretRef] = useState( authSecretValue(props.initialValue.auth), ); const [clientId, setClientId] = useState( props.initialValue.auth.kind === "oauth2" ? props.initialValue.auth.clientId : "", ); const [clientSecretRef, setClientSecretRef] = useState( clientSecretValue(props.initialValue.auth), ); const [oauthAuth, setOauthAuth] = useState< Extract | null >(props.initialValue.auth.kind === "oauth2" ? props.initialValue.auth : null); const [error, setError] = useState(null); const [oauthStatus, setOauthStatus] = useState<"idle" | "pending" | "connected">( props.initialValue.auth.kind === "oauth2" ? "connected" : "idle", ); const runOauth = async () => { if (installation.status !== "ready") { throw new Error("Workspace is still loading."); } const popup = openGoogleOauthPopup("executor-google-discovery-oauth"); if (!popup) { throw new Error("Failed opening Google OAuth popup. Allow popups for this site and try again."); } const payload: GoogleDiscoveryStartOAuthInput = { service: service.trim(), version: version.trim(), discoveryUrl: discoveryUrl.trim() || null, defaultHeaders: parseStringMap(headersText), scopes: parseScopes(scopesText), clientId: clientId.trim(), clientSecretRef: clientSecretRef ? JSON.parse(clientSecretRef) : null, clientAuthentication: clientSecretRef ? "client_secret_post" : "none", redirectUrl: new URL( GOOGLE_DISCOVERY_OAUTH_CALLBACK_PATH, window.location.origin, ).toString(), }; let started; try { started = await startOAuth({ path: { workspaceId: installation.data.scopeId, }, payload, }); } catch (cause) { popup.close(); throw cause; } popup.location.replace(started.authorizationUrl); popup.focus(); const result = await waitForOauthPopupResult(started.sessionId); if (!result.ok) { throw new Error(result.error); } if (result.mode !== "single") { throw new Error("Unexpected batch OAuth result for single-source Google setup."); } setScopesText(stringifyScopes(result.auth.scopes)); setOauthAuth(result.auth); setOauthStatus("connected"); }; return (
setName(event.target.value)} />
setService(event.target.value)} />
setVersion(event.target.value)} />
setDiscoveryUrl(event.target.value)} placeholder={defaultGoogleDiscoveryUrl(service || "sheets", version || "v4")} className="font-mono text-xs" />