675 lines · 23.0 KB · TypeScript
RawDownload
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.