817 lines · 28.5 KB · TypeScript
RawDownload
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.