import { useCallback, useEffect, useMemo, useRef, useState } from "react";import { useAtomSet, useAtomValue, Result } from "@effect-atom/atom-react"; import { openOAuthPopup, type OAuthPopupResult,} from "@executor/plugin-oauth2/react"; import { secretsAtom, setSecret } from "@executor/react/api/atoms";import { usePendingSources } from "@executor/react/api/optimistic";import { secretWriteKeys, sourceWriteKeys } from "@executor/react/api/reactivity-keys";import { useScope } from "@executor/react/api/scope-context";import { SecretPicker, type SecretPickerSecret } from "@executor/react/plugins/secret-picker";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, slugifyNamespace, 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 { IOSSpinner, Spinner } from "@executor/react/components/spinner";import { addGoogleDiscoverySource, probeGoogleDiscovery, startGoogleDiscoveryOAuth } from "./atoms"; type GoogleAuthKind = "none" | "oauth2"; // ---------------------------------------------------------------------------// Inline secret creation// --------------------------------------------------------------------------- function InlineCreateSecret(props: { headerName: string; suggestedId: string; onCreated: (secretId: string) => void; onCancel: () => void;}) { const [secretId, setSecretIdValue] = useState(props.suggestedId); const [secretName, setSecretName] = useState(props.headerName); const [secretValue, setSecretValue] = useState(""); const [saving, setSaving] = useState(false); const [error, setError] = useState<string | null>(null); const scopeId = useScope(); const doSet = useAtomSet(setSecret, { mode: "promise" }); const handleSave = async () => { if (!secretId.trim() || !secretValue.trim()) return; setSaving(true); setError(null); try { await doSet({ path: { scopeId }, payload: { id: SecretId.make(secretId.trim()), name: secretName.trim() || secretId.trim(), value: secretValue.trim(), }, reactivityKeys: [...secretWriteKeys], }); props.onCreated(secretId.trim()); } catch (e) { setError(e instanceof Error ? e.message : "Failed to save secret"); setSaving(false); } }; return ( <div className="rounded-lg border border-primary/20 bg-primary/[0.02] p-3 space-y-2.5"> <p className="text-[11px] font-semibold text-primary tracking-wide uppercase">New secret</p> <div className="grid grid-cols-2 gap-2"> <div className="space-y-1"> <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">ID</Label> <Input value={secretId} onChange={(e) => setSecretIdValue((e.target as HTMLInputElement).value)} placeholder="google-client-secret" className="h-8 text-xs font-mono" /> </div> <div className="space-y-1"> <Label className="text-[10px] uppercase tracking-wider text-muted-foreground"> Label </Label> <Input value={secretName} onChange={(e) => setSecretName((e.target as HTMLInputElement).value)} placeholder="Client Secret" className="h-8 text-xs" /> </div> </div> <div className="space-y-1"> <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">Value</Label> <Input type="password" value={secretValue} onChange={(e) => setSecretValue((e.target as HTMLInputElement).value)} placeholder="paste your client secret…" className="h-8 text-xs font-mono" /> </div> {error && <p className="text-[11px] text-destructive">{error}</p>} <div className="flex justify-end gap-1.5 pt-0.5"> <Button variant="outline" size="xs" onClick={props.onCancel}> Cancel </Button> <Button size="xs" onClick={handleSave} disabled={!secretId.trim() || !secretValue.trim() || saving} > {saving ? "Saving…" : "Create and use"} </Button> </div> </div> );} // ---------------------------------------------------------------------------// Client secret field with inline creation// --------------------------------------------------------------------------- function SecretBackedField(props: { label: string; suggestedSecretId: string; headerName: string; secretId: string | null; onSelect: (secretId: string | null) => void; secretList: readonly SecretPickerSecret[]; placeholder: string; clearable?: boolean;}) { const [creating, setCreating] = useState(false); const { label, secretId, onSelect, secretList, placeholder, clearable = true } = props; if (creating) { return ( <div className="space-y-2"> <Label>{label}</Label> <InlineCreateSecret headerName={props.headerName} suggestedId={props.suggestedSecretId} onCreated={(id) => { onSelect(id); setCreating(false); }} onCancel={() => setCreating(false)} /> </div> ); } return ( <div className="space-y-2"> <Label>{label}</Label> <div className="flex items-center gap-2"> <div className="flex-1 min-w-0"> <SecretPicker value={secretId} onSelect={onSelect} secrets={secretList} placeholder={placeholder} /> </div> <Button variant="outline" size="sm" className="shrink-0" onClick={() => setCreating(true)}> + New </Button> {clearable && secretId && ( <Button variant="outline" onClick={() => onSelect(null)}> Clear </Button> )} </div> </div> );} type GoogleDiscoveryTemplate = { id: string; name: string; summary: string; service: string; version: string; discoveryUrl: string;}; const defaultGoogleDiscoveryUrl = (service: string, version: string): string => `https://www.googleapis.com/discovery/v1/apis/${service}/${version}/rest`; const googleDiscoveryTemplate = (template: GoogleDiscoveryTemplate): GoogleDiscoveryTemplate => ({ ...template, discoveryUrl: template.discoveryUrl || defaultGoogleDiscoveryUrl(template.service, template.version),}); const GOOGLE_DISCOVERY_TEMPLATES: readonly GoogleDiscoveryTemplate[] = [ googleDiscoveryTemplate({ id: "google-calendar", name: "Google Calendar", summary: "Calendars, events, ACLs, and scheduling workflows.", service: "calendar", version: "v3", discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest", }), 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://www.googleapis.com/discovery/v1/apis/gmail/v1/rest", }), googleDiscoveryTemplate({ id: "google-docs", name: "Google Docs", summary: "Documents, structural edits, text ranges, and formatting.", service: "docs", version: "v1", discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/docs/v1/rest", }), googleDiscoveryTemplate({ id: "google-sheets", name: "Google Sheets", summary: "Spreadsheets, values, ranges, formatting, and batch updates.", service: "sheets", version: "v4", discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest", }), googleDiscoveryTemplate({ id: "google-slides", name: "Google Slides", summary: "Presentations, slides, page elements, and deck updates.", service: "slides", version: "v1", discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/slides/v1/rest", }), googleDiscoveryTemplate({ id: "google-forms", name: "Google Forms", summary: "Forms, questions, responses, quizzes, and form metadata.", service: "forms", version: "v1", discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/forms/v1/rest", }), googleDiscoveryTemplate({ id: "google-people", name: "Google People", summary: "Contacts, profiles, directory people, and contact groups.", service: "people", version: "v1", discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/people/v1/rest", }), googleDiscoveryTemplate({ id: "google-tasks", name: "Google Tasks", summary: "Task lists, task items, notes, and due dates.", service: "tasks", version: "v1", discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest", }), googleDiscoveryTemplate({ id: "google-chat", name: "Google Chat", summary: "Spaces, messages, members, reactions, and chat workflows.", service: "chat", version: "v1", discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/chat/v1/rest", }), googleDiscoveryTemplate({ id: "google-bigquery", name: "Google BigQuery", summary: "Datasets, tables, jobs, routines, and analytics workflows.", service: "bigquery", version: "v2", discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/bigquery/v2/rest", }), googleDiscoveryTemplate({ id: "google-youtube", name: "YouTube Data", summary: "Channels, playlists, videos, comments, captions, and uploads.", service: "youtube", version: "v3", discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/youtube/v3/rest", }),]; const GOOGLE_SERVICE_ICON_URLS: Record<string, string> = { calendar: "https://fonts.gstatic.com/s/i/productlogos/calendar_2020q4/v8/192px.svg", drive: "https://fonts.gstatic.com/s/i/productlogos/drive_2020q4/v8/192px.svg", gmail: "https://fonts.gstatic.com/s/i/productlogos/gmail_2020q4/v8/web-96dp/logo_gmail_2020q4_color_2x_web_96dp.png", docs: "https://fonts.gstatic.com/s/i/productlogos/docs_2020q4/v12/192px.svg", sheets: "https://fonts.gstatic.com/s/i/productlogos/sheets_2020q4/v8/192px.svg", slides: "https://fonts.gstatic.com/s/i/productlogos/slides_2020q4/v12/192px.svg", forms: "https://fonts.gstatic.com/s/i/productlogos/forms_2020q4/v6/192px.svg", people: "https://fonts.gstatic.com/s/i/productlogos/contacts/v9/192px.svg", tasks: "https://fonts.gstatic.com/s/i/productlogos/tasks/v10/192px.svg", chat: "https://fonts.gstatic.com/s/i/productlogos/chat_2020q4/v8/192px.svg", bigquery: "https://fonts.gstatic.com/s/i/productlogos/cloud/v8/192px.svg", youtube: "https://fonts.gstatic.com/s/i/productlogos/youtube/v9/192px.svg",}; function GoogleServiceIcon(props: { readonly service: string; readonly className?: string }) { const { service, className = "size-11" } = props; const src = GOOGLE_SERVICE_ICON_URLS[service] ?? GOOGLE_SERVICE_ICON_URLS.bigquery; return ( <img alt="" aria-hidden="true" className={`${className} shrink-0 object-contain`} decoding="async" draggable={false} loading="lazy" src={src} /> );} type ProbeOperation = { toolPath: string; method: string; pathTemplate: string; description: string | null;}; type ProbeResult = { name: string; title: string | null; service: string; version: string; toolCount: number; scopes: readonly string[]; operations: readonly ProbeOperation[];}; type OAuthAuth = { kind: "oauth2"; clientIdSecretId: string; clientSecretSecretId: string | null; accessTokenSecretId: string; refreshTokenSecretId: string | null; tokenType: string; expiresAt: number | null; scope: string | null; scopes: string[];}; type GoogleOAuthPopupResult = OAuthPopupResult<OAuthAuth>; const OAUTH_RESULT_CHANNEL = "executor:google-discovery-oauth-result";const OAUTH_POPUP_NAME = "google-discovery-oauth"; export default function AddGoogleDiscoverySource(props: { readonly onComplete: () => void; readonly onCancel: () => void; readonly initialUrl?: string;}) { const defaultTemplate = GOOGLE_DISCOVERY_TEMPLATES.find((template) => template.id === "google-sheets") ?? GOOGLE_DISCOVERY_TEMPLATES[0]!; const [discoveryUrl, setDiscoveryUrl] = useState( props.initialUrl ?? defaultTemplate.discoveryUrl, ); const [selectedTemplateId, setSelectedTemplateId] = useState( props.initialUrl ? "" : defaultTemplate.id, ); const selectedTemplate = GOOGLE_DISCOVERY_TEMPLATES.find((template) => template.id === selectedTemplateId) ?? null; const [authKind, setAuthKind] = useState<GoogleAuthKind>("oauth2"); const [clientIdSecretId, setClientIdSecretId] = useState<string | null>(null); const [clientSecretSecretId, setClientSecretSecretId] = useState<string | null>(null); const [probe, setProbe] = useState<ProbeResult | null>(null); const identity = useSourceIdentity({ fallbackName: probe?.name ?? selectedTemplate?.name ?? "", }); const [oauthAuth, setOauthAuth] = useState<OAuthAuth | null>(null); const [loadingProbe, setLoadingProbe] = useState(false); const [startingOAuth, setStartingOAuth] = useState(false); const [adding, setAdding] = useState(false); const [error, setError] = useState<string | null>(null); const [showScopes, setShowScopes] = useState(false); const scopeId = useScope(); const doProbe = useAtomSet(probeGoogleDiscovery, { mode: "promise" }); const doAdd = useAtomSet(addGoogleDiscoverySource, { mode: "promise" }); const doStartOAuth = useAtomSet(startGoogleDiscoveryOAuth, { mode: "promise" }); const secrets = useAtomValue(secretsAtom(scopeId)); const { beginAdd } = usePendingSources(); const canUseOAuth = useMemo(() => (probe?.scopes.length ?? 0) > 0, [probe]); const secretList: readonly SecretPickerSecret[] = Result.match(secrets, { onInitial: () => [] as SecretPickerSecret[], onFailure: () => [] as SecretPickerSecret[], onSuccess: ({ value }) => value.map((secret) => ({ id: secret.id, name: secret.name, provider: secret.provider ? String(secret.provider) : undefined, })), }); const applyTemplate = useCallback( (template: GoogleDiscoveryTemplate) => { setSelectedTemplateId(template.id); setDiscoveryUrl(template.discoveryUrl); identity.reset(); setClientSecretSecretId(null); setProbe(null); setOauthAuth(null); setError(null); setShowScopes(false); setAuthKind("oauth2"); }, [identity], ); const handleProbe = useCallback(async () => { setLoadingProbe(true); setError(null); setOauthAuth(null); setShowScopes(false); try { const result = await doProbe({ path: { scopeId }, payload: { discoveryUrl: discoveryUrl.trim() }, }); setProbe({ ...result, scopes: [...result.scopes], operations: [...result.operations], }); if (result.scopes.length === 0) { setAuthKind("none"); } } catch (e) { setProbe(null); setError(e instanceof Error ? e.message : "Failed to inspect discovery document"); } finally { setLoadingProbe(false); } }, [discoveryUrl, doProbe, scopeId]); // Keep the latest handleProbe in a ref so the debounced effect can call it // without depending on its identity (which changes every render). const handleProbeRef = useRef(handleProbe); handleProbeRef.current = handleProbe; // Auto-probe whenever the discovery URL changes (debounced). Clearing the // previous probe in the onChange handler resets the preview so a new run // will be triggered. useEffect(() => { const trimmed = discoveryUrl.trim(); if (!trimmed) return; if (probe) return; const handle = setTimeout(() => { handleProbeRef.current(); }, 400); return () => clearTimeout(handle); }, [discoveryUrl, probe]); const oauthCleanup = useRef<(() => void) | null>(null); const handleStartOAuth = useCallback(async () => { if (!probe || !clientIdSecretId) return; oauthCleanup.current?.(); oauthCleanup.current = null; setStartingOAuth(true);Lines 1–500 of 817. Load more
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAtomSet, useAtomValue, Result } from "@effect-atom/atom-react"; import { openOAuthPopup, type OAuthPopupResult, } from "@executor/plugin-oauth2/react"; import { secretsAtom, setSecret } from "@executor/react/api/atoms"; import { usePendingSources } from "@executor/react/api/optimistic"; import { secretWriteKeys, sourceWriteKeys } from "@executor/react/api/reactivity-keys"; import { useScope } from "@executor/react/api/scope-context"; import { SecretPicker, type SecretPickerSecret } from "@executor/react/plugins/secret-picker"; 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, slugifyNamespace, 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 { IOSSpinner, Spinner } from "@executor/react/components/spinner"; import { addGoogleDiscoverySource, probeGoogleDiscovery, startGoogleDiscoveryOAuth } from "./atoms"; type GoogleAuthKind = "none" | "oauth2"; // --------------------------------------------------------------------------- // Inline secret creation // --------------------------------------------------------------------------- function InlineCreateSecret(props: { headerName: string; suggestedId: string; onCreated: (secretId: string) => void; onCancel: () => void; }) { const [secretId, setSecretIdValue] = useState(props.suggestedId); const [secretName, setSecretName] = useState(props.headerName); const [secretValue, setSecretValue] = useState(""); const [saving, setSaving] = useState(false); const [error, setError] = useState<string | null>(null); const scopeId = useScope(); const doSet = useAtomSet(setSecret, { mode: "promise" }); const handleSave = async () => { if (!secretId.trim() || !secretValue.trim()) return; setSaving(true); setError(null); try { await doSet({ path: { scopeId }, payload: { id: SecretId.make(secretId.trim()), name: secretName.trim() || secretId.trim(), value: secretValue.trim(), }, reactivityKeys: [...secretWriteKeys], }); props.onCreated(secretId.trim()); } catch (e) { setError(e instanceof Error ? e.message : "Failed to save secret"); setSaving(false); } }; return ( <div className="rounded-lg border border-primary/20 bg-primary/[0.02] p-3 space-y-2.5"> <p className="text-[11px] font-semibold text-primary tracking-wide uppercase">New secret</p> <div className="grid grid-cols-2 gap-2"> <div className="space-y-1"> <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">ID</Label> <Input value={secretId} onChange={(e) => setSecretIdValue((e.target as HTMLInputElement).value)} placeholder="google-client-secret" className="h-8 text-xs font-mono" /> </div> <div className="space-y-1"> <Label className="text-[10px] uppercase tracking-wider text-muted-foreground"> Label </Label> <Input value={secretName} onChange={(e) => setSecretName((e.target as HTMLInputElement).value)} placeholder="Client Secret" className="h-8 text-xs" /> </div> </div> <div className="space-y-1"> <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">Value</Label> <Input type="password" value={secretValue} onChange={(e) => setSecretValue((e.target as HTMLInputElement).value)} placeholder="paste your client secret…" className="h-8 text-xs font-mono" /> </div> {error && <p className="text-[11px] text-destructive">{error}</p>} <div className="flex justify-end gap-1.5 pt-0.5"> <Button variant="outline" size="xs" onClick={props.onCancel}> Cancel </Button> <Button size="xs" onClick={handleSave} disabled={!secretId.trim() || !secretValue.trim() || saving} > {saving ? "Saving…" : "Create and use"} </Button> </div> </div> ); } // --------------------------------------------------------------------------- // Client secret field with inline creation // --------------------------------------------------------------------------- function SecretBackedField(props: { label: string; suggestedSecretId: string; headerName: string; secretId: string | null; onSelect: (secretId: string | null) => void; secretList: readonly SecretPickerSecret[]; placeholder: string; clearable?: boolean; }) { const [creating, setCreating] = useState(false); const { label, secretId, onSelect, secretList, placeholder, clearable = true } = props; if (creating) { return ( <div className="space-y-2"> <Label>{label}</Label> <InlineCreateSecret headerName={props.headerName} suggestedId={props.suggestedSecretId} onCreated={(id) => { onSelect(id); setCreating(false); }} onCancel={() => setCreating(false)} /> </div> ); } return ( <div className="space-y-2"> <Label>{label}</Label> <div className="flex items-center gap-2"> <div className="flex-1 min-w-0"> <SecretPicker value={secretId} onSelect={onSelect} secrets={secretList} placeholder={placeholder} /> </div> <Button variant="outline" size="sm" className="shrink-0" onClick={() => setCreating(true)}> + New </Button> {clearable && secretId && ( <Button variant="outline" onClick={() => onSelect(null)}> Clear </Button> )} </div> </div> ); } type GoogleDiscoveryTemplate = { id: string; name: string; summary: string; service: string; version: string; discoveryUrl: string; }; const defaultGoogleDiscoveryUrl = (service: string, version: string): string => `https://www.googleapis.com/discovery/v1/apis/${service}/${version}/rest`; const googleDiscoveryTemplate = (template: GoogleDiscoveryTemplate): GoogleDiscoveryTemplate => ({ ...template, discoveryUrl: template.discoveryUrl || defaultGoogleDiscoveryUrl(template.service, template.version), }); const GOOGLE_DISCOVERY_TEMPLATES: readonly GoogleDiscoveryTemplate[] = [ googleDiscoveryTemplate({ id: "google-calendar", name: "Google Calendar", summary: "Calendars, events, ACLs, and scheduling workflows.", service: "calendar", version: "v3", discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/calendar/v3/rest", }), 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://www.googleapis.com/discovery/v1/apis/gmail/v1/rest", }), googleDiscoveryTemplate({ id: "google-docs", name: "Google Docs", summary: "Documents, structural edits, text ranges, and formatting.", service: "docs", version: "v1", discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/docs/v1/rest", }), googleDiscoveryTemplate({ id: "google-sheets", name: "Google Sheets", summary: "Spreadsheets, values, ranges, formatting, and batch updates.", service: "sheets", version: "v4", discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/sheets/v4/rest", }), googleDiscoveryTemplate({ id: "google-slides", name: "Google Slides", summary: "Presentations, slides, page elements, and deck updates.", service: "slides", version: "v1", discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/slides/v1/rest", }), googleDiscoveryTemplate({ id: "google-forms", name: "Google Forms", summary: "Forms, questions, responses, quizzes, and form metadata.", service: "forms", version: "v1", discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/forms/v1/rest", }), googleDiscoveryTemplate({ id: "google-people", name: "Google People", summary: "Contacts, profiles, directory people, and contact groups.", service: "people", version: "v1", discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/people/v1/rest", }), googleDiscoveryTemplate({ id: "google-tasks", name: "Google Tasks", summary: "Task lists, task items, notes, and due dates.", service: "tasks", version: "v1", discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/tasks/v1/rest", }), googleDiscoveryTemplate({ id: "google-chat", name: "Google Chat", summary: "Spaces, messages, members, reactions, and chat workflows.", service: "chat", version: "v1", discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/chat/v1/rest", }), googleDiscoveryTemplate({ id: "google-bigquery", name: "Google BigQuery", summary: "Datasets, tables, jobs, routines, and analytics workflows.", service: "bigquery", version: "v2", discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/bigquery/v2/rest", }), googleDiscoveryTemplate({ id: "google-youtube", name: "YouTube Data", summary: "Channels, playlists, videos, comments, captions, and uploads.", service: "youtube", version: "v3", discoveryUrl: "https://www.googleapis.com/discovery/v1/apis/youtube/v3/rest", }), ]; const GOOGLE_SERVICE_ICON_URLS: Record<string, string> = { calendar: "https://fonts.gstatic.com/s/i/productlogos/calendar_2020q4/v8/192px.svg", drive: "https://fonts.gstatic.com/s/i/productlogos/drive_2020q4/v8/192px.svg", gmail: "https://fonts.gstatic.com/s/i/productlogos/gmail_2020q4/v8/web-96dp/logo_gmail_2020q4_color_2x_web_96dp.png", docs: "https://fonts.gstatic.com/s/i/productlogos/docs_2020q4/v12/192px.svg", sheets: "https://fonts.gstatic.com/s/i/productlogos/sheets_2020q4/v8/192px.svg", slides: "https://fonts.gstatic.com/s/i/productlogos/slides_2020q4/v12/192px.svg", forms: "https://fonts.gstatic.com/s/i/productlogos/forms_2020q4/v6/192px.svg", people: "https://fonts.gstatic.com/s/i/productlogos/contacts/v9/192px.svg", tasks: "https://fonts.gstatic.com/s/i/productlogos/tasks/v10/192px.svg", chat: "https://fonts.gstatic.com/s/i/productlogos/chat_2020q4/v8/192px.svg", bigquery: "https://fonts.gstatic.com/s/i/productlogos/cloud/v8/192px.svg", youtube: "https://fonts.gstatic.com/s/i/productlogos/youtube/v9/192px.svg", }; function GoogleServiceIcon(props: { readonly service: string; readonly className?: string }) { const { service, className = "size-11" } = props; const src = GOOGLE_SERVICE_ICON_URLS[service] ?? GOOGLE_SERVICE_ICON_URLS.bigquery; return ( <img alt="" aria-hidden="true" className={`${className} shrink-0 object-contain`} decoding="async" draggable={false} loading="lazy" src={src} /> ); } type ProbeOperation = { toolPath: string; method: string; pathTemplate: string; description: string | null; }; type ProbeResult = { name: string; title: string | null; service: string; version: string; toolCount: number; scopes: readonly string[]; operations: readonly ProbeOperation[]; }; type OAuthAuth = { kind: "oauth2"; clientIdSecretId: string; clientSecretSecretId: string | null; accessTokenSecretId: string; refreshTokenSecretId: string | null; tokenType: string; expiresAt: number | null; scope: string | null; scopes: string[]; }; type GoogleOAuthPopupResult = OAuthPopupResult<OAuthAuth>; const OAUTH_RESULT_CHANNEL = "executor:google-discovery-oauth-result"; const OAUTH_POPUP_NAME = "google-discovery-oauth"; export default function AddGoogleDiscoverySource(props: { readonly onComplete: () => void; readonly onCancel: () => void; readonly initialUrl?: string; }) { const defaultTemplate = GOOGLE_DISCOVERY_TEMPLATES.find((template) => template.id === "google-sheets") ?? GOOGLE_DISCOVERY_TEMPLATES[0]!; const [discoveryUrl, setDiscoveryUrl] = useState( props.initialUrl ?? defaultTemplate.discoveryUrl, ); const [selectedTemplateId, setSelectedTemplateId] = useState( props.initialUrl ? "" : defaultTemplate.id, ); const selectedTemplate = GOOGLE_DISCOVERY_TEMPLATES.find((template) => template.id === selectedTemplateId) ?? null; const [authKind, setAuthKind] = useState<GoogleAuthKind>("oauth2"); const [clientIdSecretId, setClientIdSecretId] = useState<string | null>(null); const [clientSecretSecretId, setClientSecretSecretId] = useState<string | null>(null); const [probe, setProbe] = useState<ProbeResult | null>(null); const identity = useSourceIdentity({ fallbackName: probe?.name ?? selectedTemplate?.name ?? "", }); const [oauthAuth, setOauthAuth] = useState<OAuthAuth | null>(null); const [loadingProbe, setLoadingProbe] = useState(false); const [startingOAuth, setStartingOAuth] = useState(false); const [adding, setAdding] = useState(false); const [error, setError] = useState<string | null>(null); const [showScopes, setShowScopes] = useState(false); const scopeId = useScope(); const doProbe = useAtomSet(probeGoogleDiscovery, { mode: "promise" }); const doAdd = useAtomSet(addGoogleDiscoverySource, { mode: "promise" }); const doStartOAuth = useAtomSet(startGoogleDiscoveryOAuth, { mode: "promise" }); const secrets = useAtomValue(secretsAtom(scopeId)); const { beginAdd } = usePendingSources(); const canUseOAuth = useMemo(() => (probe?.scopes.length ?? 0) > 0, [probe]); const secretList: readonly SecretPickerSecret[] = Result.match(secrets, { onInitial: () => [] as SecretPickerSecret[], onFailure: () => [] as SecretPickerSecret[], onSuccess: ({ value }) => value.map((secret) => ({ id: secret.id, name: secret.name, provider: secret.provider ? String(secret.provider) : undefined, })), }); const applyTemplate = useCallback( (template: GoogleDiscoveryTemplate) => { setSelectedTemplateId(template.id); setDiscoveryUrl(template.discoveryUrl); identity.reset(); setClientSecretSecretId(null); setProbe(null); setOauthAuth(null); setError(null); setShowScopes(false); setAuthKind("oauth2"); }, [identity], ); const handleProbe = useCallback(async () => { setLoadingProbe(true); setError(null); setOauthAuth(null); setShowScopes(false); try { const result = await doProbe({ path: { scopeId }, payload: { discoveryUrl: discoveryUrl.trim() }, }); setProbe({ ...result, scopes: [...result.scopes], operations: [...result.operations], }); if (result.scopes.length === 0) { setAuthKind("none"); } } catch (e) { setProbe(null); setError(e instanceof Error ? e.message : "Failed to inspect discovery document"); } finally { setLoadingProbe(false); } }, [discoveryUrl, doProbe, scopeId]); // Keep the latest handleProbe in a ref so the debounced effect can call it // without depending on its identity (which changes every render). const handleProbeRef = useRef(handleProbe); handleProbeRef.current = handleProbe; // Auto-probe whenever the discovery URL changes (debounced). Clearing the // previous probe in the onChange handler resets the preview so a new run // will be triggered. useEffect(() => { const trimmed = discoveryUrl.trim(); if (!trimmed) return; if (probe) return; const handle = setTimeout(() => { handleProbeRef.current(); }, 400); return () => clearTimeout(handle); }, [discoveryUrl, probe]); const oauthCleanup = useRef<(() => void) | null>(null); const handleStartOAuth = useCallback(async () => { if (!probe || !clientIdSecretId) return; oauthCleanup.current?.(); oauthCleanup.current = null; setStartingOAuth(true);