import { useCallback, useEffect, useMemo, useRef, useState } from "react";import { useAtomSet, useAtomValue, Result } from "@effect-atom/atom-react"; import { useScope, SecretPicker, secretsAtom, type SecretPickerSecret,} from "@executor/react";import { Badge } from "@executor/ui/components/badge";import { Button } from "@executor/ui/components/button";import { Collapsible, CollapsibleContent, CollapsibleTrigger,} from "@executor/ui/components/collapsible";import { Input } from "@executor/ui/components/input";import { Label } from "@executor/ui/components/label";import { RadioGroup, RadioGroupItem } from "@executor/ui/components/radio-group";import { Spinner } from "@executor/ui/components/spinner";import { addGoogleDiscoverySource, probeGoogleDiscovery, startGoogleDiscoveryOAuth,} from "./atoms"; 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 ProbeResult = { name: string; title: string | null; service: string; version: string; toolCount: number; scopes: readonly string[];}; type OAuthAuth = { kind: "oauth2"; clientId: string; clientSecretSecretId: string | null; accessTokenSecretId: string; refreshTokenSecretId: string | null; tokenType: string; expiresAt: number | null; scope: string | null; scopes: string[];}; type OAuthPopupResult = | ({ type: "executor:oauth-result"; ok: true; sessionId: string; } & OAuthAuth) | { type: "executor:oauth-result"; ok: false; sessionId: null; error: string; }; function openOAuthPopup( url: string, onResult: (data: OAuthPopupResult) => void, onClosed?: () => void,): void { const w = 640; const h = 760; const left = window.screenX + (window.outerWidth - w) / 2; const top = window.screenY + (window.outerHeight - h) / 2; const popup = window.open( url, "google-discovery-oauth", `width=${w},height=${h},left=${left},top=${top},popup=1`, ); let settled = false; const settle = () => { settled = true; window.removeEventListener("message", onMessage); }; const onMessage = (event: MessageEvent) => { if ( event.origin === window.location.origin && event.data?.type === "executor:oauth-result" && !settled ) { settle(); onResult(event.data as OAuthPopupResult); } }; window.addEventListener("message", onMessage); if (popup) { const interval = window.setInterval(() => { if (popup.closed) { window.clearInterval(interval); if (!settled) { settle(); onClosed?.(); } } }, 500); }} 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 [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 [clientSecretSecretId, setClientSecretSecretId] = useState<string | null>(null); const [probe, setProbe] = useState<ProbeResult | null>(null); 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 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); setName(template.name); setClientSecretSecretId(null); setProbe(null); setOauthAuth(null); setError(null); setShowScopes(false); setAuthKind("oauth2"); }, []); 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] }); if (!name.trim()) { setName(result.name); } 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, name]); const autoProbed = useRef(false); useEffect(() => { if (props.initialUrl && !autoProbed.current) { autoProbed.current = true; handleProbe(); } }, []); // eslint-disable-line react-hooks/exhaustive-deps const handleStartOAuth = useCallback(async () => { if (!probe) return; setStartingOAuth(true); setError(null); try { const response = await doStartOAuth({ path: { scopeId }, payload: { name: name.trim() || probe.name, discoveryUrl: discoveryUrl.trim(), clientId: clientId.trim(), clientSecretSecretId, redirectUrl: `${window.location.origin}/v1/google-discovery/oauth/callback`, scopes: probe.scopes, }, }); openOAuthPopup( response.authorizationUrl, (result) => { setStartingOAuth(false); if (result.ok) { setOauthAuth({ kind: "oauth2", clientId: result.clientId, clientSecretSecretId: result.clientSecretSecretId, accessTokenSecretId: result.accessTokenSecretId, refreshTokenSecretId: result.refreshTokenSecretId, tokenType: result.tokenType, expiresAt: result.expiresAt, scope: result.scope, scopes: [...result.scopes], }); setError(null); } else { setError(result.error); } }, () => { setStartingOAuth(false); }, ); } catch (e) { setStartingOAuth(false); setError(e instanceof Error ? e.message : "Failed to start OAuth"); } }, [probe, doStartOAuth, name, discoveryUrl, clientId, clientSecretSecretId]); const handleAdd = useCallback(async () => { if (!probe) return; setAdding(true); setError(null); try { await doAdd({ path: { scopeId }, payload: { name: name.trim() || probe.name, discoveryUrl: discoveryUrl.trim(), auth: authKind === "oauth2" ? oauthAuth ?? { kind: "none" as const } : { kind: "none" as const }, }, }); props.onComplete(); } catch (e) { setError(e instanceof Error ? e.message : "Failed to add source"); setAdding(false); } }, [probe, doAdd, name, discoveryUrl, authKind, oauthAuth, props]); const addDisabled = !probe || adding || (authKind === "oauth2" && (!canUseOAuth || oauthAuth === null)); return ( <div className="space-y-6"> <div> <h1 className="text-xl font-semibold text-foreground"> Add Google Discovery Source </h1> <p className="mt-1 text-[13px] text-muted-foreground"> Connect a Google API from its Discovery document and register its methods as tools. </p> </div> <section className="space-y-2"> <div className="flex items-center justify-between gap-3"> <Label>Presets</Label> <span className="text-xs text-muted-foreground"> Select a Google API to prefill the source. </span> </div> <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"> {GOOGLE_DISCOVERY_TEMPLATES.map((template) => { const selected = template.id === selectedTemplateId; return ( <button key={template.id} type="button" onClick={() => applyTemplate(template)} className={`relative rounded-xl border px-4 py-3 text-left transition-colors ${ selected ? "border-primary bg-primary/5 shadow-[0_0_0_1px_rgba(0,0,0,0.02)]" : "border-border bg-card hover:border-primary/30 hover:bg-card/80" }`} > {selected && ( <Badge variant="secondary" className="absolute top-3 right-3"> Selected </Badge> )} <div className="flex min-w-0 gap-3 pr-20"> <div className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border/60 bg-background/80 shadow-xs"> <GoogleServiceIcon service={template.service} className="size-6" /> </div> <div className="min-w-0"> <p className="text-sm font-semibold text-foreground"> {template.name} </p> <p className="mt-1 text-xs text-muted-foreground"> {template.summary} </p> </div> </div> <div className="mt-3 flex items-center justify-between gap-3"> <p className="text-[11px] font-mono text-muted-foreground"> {template.service} · {template.version} </p> <div className="h-px flex-1 bg-border/70" /> </div> </button> ); })} </div> </section> <section className="space-y-2"> <Label>Discovery URL</Label> <div className="flex gap-2"> <Input value={discoveryUrl} onChange={(e) => { setSelectedTemplateId(""); setDiscoveryUrl((e.target as HTMLInputElement).value); }}Lines 1–500 of 675. Load more
import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import { useAtomSet, useAtomValue, Result } from "@effect-atom/atom-react"; import { useScope, SecretPicker, secretsAtom, type SecretPickerSecret, } from "@executor/react"; import { Badge } from "@executor/ui/components/badge"; import { Button } from "@executor/ui/components/button"; import { Collapsible, CollapsibleContent, CollapsibleTrigger, } from "@executor/ui/components/collapsible"; import { Input } from "@executor/ui/components/input"; import { Label } from "@executor/ui/components/label"; import { RadioGroup, RadioGroupItem } from "@executor/ui/components/radio-group"; import { Spinner } from "@executor/ui/components/spinner"; import { addGoogleDiscoverySource, probeGoogleDiscovery, startGoogleDiscoveryOAuth, } from "./atoms"; 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 ProbeResult = { name: string; title: string | null; service: string; version: string; toolCount: number; scopes: readonly string[]; }; type OAuthAuth = { kind: "oauth2"; clientId: string; clientSecretSecretId: string | null; accessTokenSecretId: string; refreshTokenSecretId: string | null; tokenType: string; expiresAt: number | null; scope: string | null; scopes: string[]; }; type OAuthPopupResult = | ({ type: "executor:oauth-result"; ok: true; sessionId: string; } & OAuthAuth) | { type: "executor:oauth-result"; ok: false; sessionId: null; error: string; }; function openOAuthPopup( url: string, onResult: (data: OAuthPopupResult) => void, onClosed?: () => void, ): void { const w = 640; const h = 760; const left = window.screenX + (window.outerWidth - w) / 2; const top = window.screenY + (window.outerHeight - h) / 2; const popup = window.open( url, "google-discovery-oauth", `width=${w},height=${h},left=${left},top=${top},popup=1`, ); let settled = false; const settle = () => { settled = true; window.removeEventListener("message", onMessage); }; const onMessage = (event: MessageEvent) => { if ( event.origin === window.location.origin && event.data?.type === "executor:oauth-result" && !settled ) { settle(); onResult(event.data as OAuthPopupResult); } }; window.addEventListener("message", onMessage); if (popup) { const interval = window.setInterval(() => { if (popup.closed) { window.clearInterval(interval); if (!settled) { settle(); onClosed?.(); } } }, 500); } } 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 [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 [clientSecretSecretId, setClientSecretSecretId] = useState<string | null>(null); const [probe, setProbe] = useState<ProbeResult | null>(null); 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 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); setName(template.name); setClientSecretSecretId(null); setProbe(null); setOauthAuth(null); setError(null); setShowScopes(false); setAuthKind("oauth2"); }, []); 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] }); if (!name.trim()) { setName(result.name); } 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, name]); const autoProbed = useRef(false); useEffect(() => { if (props.initialUrl && !autoProbed.current) { autoProbed.current = true; handleProbe(); } }, []); // eslint-disable-line react-hooks/exhaustive-deps const handleStartOAuth = useCallback(async () => { if (!probe) return; setStartingOAuth(true); setError(null); try { const response = await doStartOAuth({ path: { scopeId }, payload: { name: name.trim() || probe.name, discoveryUrl: discoveryUrl.trim(), clientId: clientId.trim(), clientSecretSecretId, redirectUrl: `${window.location.origin}/v1/google-discovery/oauth/callback`, scopes: probe.scopes, }, }); openOAuthPopup( response.authorizationUrl, (result) => { setStartingOAuth(false); if (result.ok) { setOauthAuth({ kind: "oauth2", clientId: result.clientId, clientSecretSecretId: result.clientSecretSecretId, accessTokenSecretId: result.accessTokenSecretId, refreshTokenSecretId: result.refreshTokenSecretId, tokenType: result.tokenType, expiresAt: result.expiresAt, scope: result.scope, scopes: [...result.scopes], }); setError(null); } else { setError(result.error); } }, () => { setStartingOAuth(false); }, ); } catch (e) { setStartingOAuth(false); setError(e instanceof Error ? e.message : "Failed to start OAuth"); } }, [probe, doStartOAuth, name, discoveryUrl, clientId, clientSecretSecretId]); const handleAdd = useCallback(async () => { if (!probe) return; setAdding(true); setError(null); try { await doAdd({ path: { scopeId }, payload: { name: name.trim() || probe.name, discoveryUrl: discoveryUrl.trim(), auth: authKind === "oauth2" ? oauthAuth ?? { kind: "none" as const } : { kind: "none" as const }, }, }); props.onComplete(); } catch (e) { setError(e instanceof Error ? e.message : "Failed to add source"); setAdding(false); } }, [probe, doAdd, name, discoveryUrl, authKind, oauthAuth, props]); const addDisabled = !probe || adding || (authKind === "oauth2" && (!canUseOAuth || oauthAuth === null)); return ( <div className="space-y-6"> <div> <h1 className="text-xl font-semibold text-foreground"> Add Google Discovery Source </h1> <p className="mt-1 text-[13px] text-muted-foreground"> Connect a Google API from its Discovery document and register its methods as tools. </p> </div> <section className="space-y-2"> <div className="flex items-center justify-between gap-3"> <Label>Presets</Label> <span className="text-xs text-muted-foreground"> Select a Google API to prefill the source. </span> </div> <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3"> {GOOGLE_DISCOVERY_TEMPLATES.map((template) => { const selected = template.id === selectedTemplateId; return ( <button key={template.id} type="button" onClick={() => applyTemplate(template)} className={`relative rounded-xl border px-4 py-3 text-left transition-colors ${ selected ? "border-primary bg-primary/5 shadow-[0_0_0_1px_rgba(0,0,0,0.02)]" : "border-border bg-card hover:border-primary/30 hover:bg-card/80" }`} > {selected && ( <Badge variant="secondary" className="absolute top-3 right-3"> Selected </Badge> )} <div className="flex min-w-0 gap-3 pr-20"> <div className="flex size-10 shrink-0 items-center justify-center rounded-xl border border-border/60 bg-background/80 shadow-xs"> <GoogleServiceIcon service={template.service} className="size-6" /> </div> <div className="min-w-0"> <p className="text-sm font-semibold text-foreground"> {template.name} </p> <p className="mt-1 text-xs text-muted-foreground"> {template.summary} </p> </div> </div> <div className="mt-3 flex items-center justify-between gap-3"> <p className="text-[11px] font-mono text-muted-foreground"> {template.service} · {template.version} </p> <div className="h-px flex-1 bg-border/70" /> </div> </button> ); })} </div> </section> <section className="space-y-2"> <Label>Discovery URL</Label> <div className="flex gap-2"> <Input value={discoveryUrl} onChange={(e) => { setSelectedTemplateId(""); setDiscoveryUrl((e.target as HTMLInputElement).value); }}