761 lines · 27.3 KB · TypeScript
RawDownload
import { useState, useEffect, useRef } from "react";import { useAtomSet, useAtomValue, useAtomRefresh, Result } from "@effect-atom/atom-react";import { Option } from "effect"; import {  secretsAtom,  setSecret,  resolveSecret,  useScope,  SecretPicker,  type SecretPickerSecret,} from "@executor/react";import { SecretId } from "@executor/sdk";import { Button } from "@executor/ui/components/button";import { Input } from "@executor/ui/components/input";import { Label } from "@executor/ui/components/label";import { Textarea } from "@executor/ui/components/textarea";import { Badge } from "@executor/ui/components/badge";import { RadioGroup, RadioGroupItem } from "@executor/ui/components/radio-group";import { Spinner } from "@executor/ui/components/spinner";import { previewOpenApiSpec, addOpenApiSpec } from "./atoms";import type { SpecPreview, HeaderPreset } from "../sdk/preview";import type { HeaderValue } from "../sdk/types"; // ---------------------------------------------------------------------------// Inline secret creation// --------------------------------------------------------------------------- function InlineCreateSecret(props: {  headerName: string;  suggestedId: string;  onCreated: (secretId: string) => void;  onCancel: () => void;}) {  const [secretId, setSecretId] = 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 refreshSecrets = useAtomRefresh(secretsAtom(scopeId));   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(),          purpose: `Auth header: ${props.headerName}`,        },      });      refreshSecrets();      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) => setSecretId((e.target as HTMLInputElement).value)}            placeholder="my-api-token"            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="API Token"            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 token or key…"          className="h-8 text-xs font-mono"        />      </div>      {error && <p className="text-[11px] text-destructive">{error}</p>}      <div className="flex 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 & use"}        </Button>      </div>    </div>  );} // ---------------------------------------------------------------------------// Header value preview — shows what the header will look like// --------------------------------------------------------------------------- type ResolveState =  | { status: "hidden" }  | { status: "loading" }  | { status: "revealed"; value: string }  | { status: "error" }; function HeaderValuePreview(props: {  headerName: string;  secretId: string;  prefix?: string;}) {  const { headerName, secretId, prefix } = props;  const scopeId = useScope();  const [state, setState] = useState<ResolveState>({ status: "hidden" });  const doResolve = useAtomSet(resolveSecret, { mode: "promise" });   const handleToggle = async () => {    if (state.status === "revealed") {      setState({ status: "hidden" });      return;    }    setState({ status: "loading" });    try {      const result = await doResolve({        path: {          scopeId,          secretId: SecretId.make(secretId),        },      });      setState({ status: "revealed", value: result.value });    } catch {      setState({ status: "error" });    }  };   const displayValue =    state.status === "revealed" ? state.value    : state.status === "error" ? "failed to resolve"    : "•".repeat(12);  const isLoading = state.status === "loading";  const isRevealed = state.status === "revealed";   return (    <div className="flex items-center gap-1.5 rounded-md border border-border bg-muted/30 px-2.5 py-1.5 font-mono text-xs">      <span className="text-muted-foreground shrink-0">{headerName}:</span>      <span className="text-foreground truncate">        {prefix && <span className="text-muted-foreground">{prefix}</span>}        {displayValue}      </span>      <Button        variant="ghost"        size="icon-xs"        className="ml-auto shrink-0"        onClick={handleToggle}        disabled={isLoading}      >        {isLoading ? (          <Spinner className="size-3" />        ) : isRevealed ? (          <svg viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round">            <path d="M2 2l12 12" />            <path d="M6.5 6.5a2 2 0 0 0 3 3" />            <path d="M3.5 5.5C2.3 6.7 1.5 8 1.5 8s2.5 4.5 6.5 4.5c1 0 1.9-.3 2.7-.7" />            <path d="M10.7 10.7c2-1.4 3.3-3.2 3.8-3.7 0 0-2.5-5-6.5-5-.7 0-1.4.1-2 .4" />          </svg>        ) : (          <svg viewBox="0 0 16 16" className="size-3.5" fill="none" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round">            <path d="M1.5 8s2.5-4.5 6.5-4.5S14.5 8 14.5 8s-2.5 4.5-6.5 4.5S1.5 8 1.5 8z" />            <circle cx="8" cy="8" r="2" />          </svg>        )}      </Button>    </div>  );} // ---------------------------------------------------------------------------// Header presets// --------------------------------------------------------------------------- const HEADER_PRESETS = [  { key: "bearer", label: "Bearer Token", name: "Authorization", prefix: "Bearer " },  { key: "basic", label: "Basic Auth", name: "Authorization", prefix: "Basic " },  { key: "api-key", label: "API Key", name: "X-API-Key" },  { key: "auth-token", label: "Auth Token", name: "X-Auth-Token" },  { key: "access-token", label: "Access Token", name: "X-Access-Token" },  { key: "cookie", label: "Cookie", name: "Cookie" },  { key: "custom", label: "Custom", name: "" },] as const; // ---------------------------------------------------------------------------// Custom header row — pick a preset, then pick a secret// --------------------------------------------------------------------------- function CustomHeaderRow(props: {  name: string;  prefix?: string;  presetKey?: string;  secretId: string | null;  onChange: (update: { name: string; prefix?: string; presetKey?: string }) => void;  onSelectSecret: (secretId: string) => void;  onRemove: () => void;  existingSecrets: readonly SecretPickerSecret[];}) {  const [creating, setCreating] = useState(false);  const { name, prefix, presetKey, secretId, onChange, onSelectSecret, onRemove, existingSecrets } = props;   const isCustom = presetKey === "custom";  const suggestedId = name.toLowerCase().replace(/[^a-z0-9]+/g, "-") || "custom-header";   if (creating) {    return (      <InlineCreateSecret        headerName={name || "Custom Header"}        suggestedId={suggestedId}        onCreated={(id) => {          onSelectSecret(id);          setCreating(false);        }}        onCancel={() => setCreating(false)}      />    );  }   return (    <div className="rounded-lg border border-border bg-card p-3 space-y-2.5">      <div className="flex items-center justify-between">        <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">Header</Label>        <Button variant="ghost" size="xs" className="text-muted-foreground hover:text-destructive" onClick={onRemove}>          Remove        </Button>      </div>       {/* Preset chips */}      <div className="flex flex-wrap gap-1">        {HEADER_PRESETS.map((p) => (          <button            key={p.key}            onClick={() =>              onChange({                name: p.name,                prefix: (p as { prefix?: string }).prefix,                presetKey: p.key,              })            }            className={`rounded-md border px-2 py-1 text-xs font-medium transition-colors ${              presetKey === p.key                ? "border-primary/50 bg-primary/10 text-primary"                : "border-border bg-background text-muted-foreground hover:text-foreground hover:bg-accent/50"            }`}          >            {p.label}          </button>        ))}      </div>       {/* Name + prefix fields — always visible once a preset is picked */}      {presetKey !== undefined && (        <div className="grid grid-cols-2 gap-2">          <div className="space-y-1">            <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">Name</Label>            <Input              value={name}              onChange={(e) => onChange({ name: (e.target as HTMLInputElement).value, prefix, presetKey: isCustom ? "custom" : presetKey })}              placeholder="Authorization"              className="h-8 text-xs font-mono"            />          </div>          <div className="space-y-1">            <Label className="text-[10px] uppercase tracking-wider text-muted-foreground">Prefix <span className="normal-case tracking-normal font-normal text-muted-foreground/60">(opt.)</span></Label>            <Input              value={prefix ?? ""}              onChange={(e) => onChange({ name, prefix: (e.target as HTMLInputElement).value || undefined, presetKey: isCustom ? "custom" : presetKey })}              placeholder="Bearer "              className="h-8 text-xs font-mono"            />          </div>        </div>      )}       {/* Secret picker */}      {presetKey !== undefined && name.trim() && (        <div className="flex items-center gap-1.5">          <div className="flex-1 min-w-0">            <SecretPicker              value={secretId}              onSelect={onSelectSecret}              secrets={existingSecrets}            />          </div>          <Button variant="outline" size="sm" className="shrink-0" onClick={() => setCreating(true)}>            + New          </Button>        </div>      )}       {/* Preview */}      {secretId && name.trim() && (        <HeaderValuePreview          headerName={name.trim()}          secretId={secretId}          prefix={prefix}        />      )}    </div>  );} // ---------------------------------------------------------------------------// Helpers// --------------------------------------------------------------------------- function prefixForHeader(preset: HeaderPreset, headerName: string): string | undefined {  const label = preset.label.toLowerCase();  if (headerName.toLowerCase() === "authorization") {    if (label.includes("bearer")) return "Bearer ";    if (label.includes("basic")) return "Basic ";  }  return undefined;} function matchPresetKey(name: string, prefix?: string): string {  if (name === "Authorization" && prefix === "Bearer ") return "bearer";  if (name === "Authorization" && prefix === "Basic ") return "basic";  if (name === "X-API-Key") return "api-key";  if (name === "X-Auth-Token") return "auth-token";  if (name === "X-Access-Token") return "access-token";  if (name === "Cookie") return "cookie";  return "custom";} function presetEntriesFromHeaderPreset(preset: HeaderPreset) {  return preset.secretHeaders.map((headerName) => {    const prefix = prefixForHeader(preset, headerName);    return {      name: headerName,      secretId: null as string | null,      prefix,      presetKey: matchPresetKey(headerName, prefix),      fromPreset: true,    };  });} // ---------------------------------------------------------------------------// Main component — single progressive form// --------------------------------------------------------------------------- export default function AddOpenApiSource(props: {  onComplete: () => void;  onCancel: () => void;  initialUrl?: string;}) {  // Spec input  const [specUrl, setSpecUrl] = useState(props.initialUrl ?? "");  const [analyzing, setAnalyzing] = useState(false);  const [analyzeError, setAnalyzeError] = useState<string | null>(null);   // After analysis  const [preview, setPreview] = useState<SpecPreview | null>(null);  const [baseUrl, setBaseUrl] = useState("");   // Auth  const [presetIndex, setPresetIndex] = useState(0);  const [customHeaders, setCustomHeaders] = useState<Array<{ name: string; secretId: string | null; prefix?: string; presetKey?: string; fromPreset?: boolean }>>([]);   // Submit  const [adding, setAdding] = useState(false);  const [addError, setAddError] = useState<string | null>(null);   const scopeId = useScope();  const doPreview = useAtomSet(previewOpenApiSpec, { mode: "promise" });  const doAdd = useAtomSet(addOpenApiSpec, { mode: "promise" });  const secrets = useAtomValue(secretsAtom(scopeId));  const autoAnalyzed = useRef(false);   useEffect(() => {    if (props.initialUrl && !autoAnalyzed.current) {      autoAnalyzed.current = true;      handleAnalyze();    }  }, []); // eslint-disable-line react-hooks/exhaustive-deps   const secretList: readonly SecretPickerSecret[] = Result.match(secrets, {    onInitial: () => [] as SecretPickerSecret[],    onFailure: () => [] as SecretPickerSecret[],    onSuccess: ({ value }) =>      value.map((s) => ({        id: s.id,        name: s.name,        provider: s.provider ? String(s.provider) : undefined,      })),  });   // ---- Derived state ----   const presets = preview?.headerPresets ?? [];  const hasAuth = presets.length > 0;  const servers = (preview?.servers ?? []) as Array<{ url?: string }>;   const allHeaders: Record<string, HeaderValue> = {};  for (const ch of customHeaders) {    if (ch.name.trim() && ch.secretId) {      allHeaders[ch.name.trim()] = { secretId: ch.secretId, ...(ch.prefix ? { prefix: ch.prefix } : {}) };    }  }  const hasHeaders = Object.keys(allHeaders).length > 0;   const customHeadersValid = customHeaders.every(    (ch) => ch.name.trim() && ch.secretId,  );   const canAdd =    preview !== null &&    baseUrl.trim().length > 0 &&    (customHeaders.length === 0 || customHeadersValid);   // ---- Handlers ----   const handleAnalyze = async () => {    setAnalyzing(true);    setAnalyzeError(null);    setAddError(null);    try {      const result = await doPreview({        path: { scopeId },        payload: { spec: specUrl },      });      setPreview(result);       const firstUrl = (result.servers as Array<{ url?: string }>)?.[0]?.url;      if (firstUrl) setBaseUrl(firstUrl);       const newPresetIndex = result.headerPresets.length > 0 ? 0 : -1;      setPresetIndex(newPresetIndex);      setCustomHeaders(        newPresetIndex >= 0          ? presetEntriesFromHeaderPreset(result.headerPresets[newPresetIndex])          : [],      );    } catch (e) {      setAnalyzeError(e instanceof Error ? e.message : "Failed to parse spec");    } finally {      setAnalyzing(false);    }  };   const selectPreset = (index: number) => {    setPresetIndex(index);    if (index === -1) {      // "None" — clear everything      setCustomHeaders([]);    } else if (index === -2) {      // "Custom" — keep user headers, drop preset-derived, seed if empty      const userHeaders = customHeaders.filter((h) => !h.fromPreset);      setCustomHeaders(userHeaders.length > 0 ? userHeaders : [{ name: "", secretId: null, presetKey: undefined }]);    } else {      // Preset strategy — replace preset-derived headers, keep user headers      const preset = presets[index];      const userHeaders = customHeaders.filter((h) => !h.fromPreset);      setCustomHeaders(preset ? [...presetEntriesFromHeaderPreset(preset), ...userHeaders] : userHeaders);    }  };   const addCustomHeader = () => {    if (presetIndex === -1) setPresetIndex(-2);    setCustomHeaders([...customHeaders, { name: "", secretId: null, presetKey: undefined }]);  };   const updateCustomHeader = (index: number, update: Partial<{ name: string; secretId: string | null; prefix?: string; presetKey?: string }>) => {    setCustomHeaders(customHeaders.map((ch, i) => (i === index ? { ...ch, ...update } : ch)));  };   const removeCustomHeader = (index: number) => {    setCustomHeaders(customHeaders.filter((_, i) => i !== index));  };   const handleAdd = async () => {    setAdding(true);    setAddError(null);    try {

Lines 1–500 of 761.