270 lines · 11.5 KB · TypeScript
RawDownload
// ---------------------------------------------------------------------------// Auth-method LIST editor — the add-flow's "How does this API authenticate?"// section, as a list. Every plugin registers EVERY declared method (P6: add// without auth, connect later), so the add flow edits a list of generic// `AuthTemplateEditorValue` rows seeded from detection (spec analysis, endpoint// probe, …) with add/remove and a per-row `AuthTemplateEditor`.//// Composition: `useAuthMethodList` is the headless row state (seeding,// edit/add/remove); `AuthMethodListEditor` is the presentation. Plugins own// only the codec at the edges — seeds in (detection → editor values) and// submit out (editor values → wire templates).// --------------------------------------------------------------------------- import { useCallback, useEffect, useRef, useState } from "react";import { LockIcon, PlusIcon, XIcon } from "lucide-react"; import { PlacementLine } from "../lib/auth-placements";import { Button } from "./button";import { FieldLabel } from "./field";import {  AuthTemplateEditor,  emptyApiKeyValue,  type AuthTemplateEditorKind,  type AuthTemplateEditorPreset,  type AuthTemplateEditorValue,} from "./auth-template-editor"; export interface AuthMethodSeed {  readonly value: AuthTemplateEditorValue;  /** The detected method's stable slug — an unedited seeded row submits with   *  its EXACT original slug (preserving connections bound against it). */  readonly slug?: string;  /** Detection label (e.g. the spec's security-scheme name) shown on the row. */  readonly label?: string;} export interface AuthMethodRow {  readonly value: AuthTemplateEditorValue;  /** True when this row came from detection (a seed), false when the user added   *  it. Detected rows are immutable — the spec/probe declared them — so the   *  editor renders them read-only. Not inferred from `seedSlug`: some plugins   *  (MCP) seed a detected method with a label but no slug. */  readonly seeded: boolean;  readonly seedSlug?: string;  readonly seedLabel?: string;} export interface AuthMethodListState {  readonly rows: readonly AuthMethodRow[];  readonly setRowAt: (index: number, next: AuthTemplateEditorValue) => void;  readonly removeRowAt: (index: number) => void;  readonly addRow: () => void;} /** Headless row state for the method list. Re-seeds whenever `seeds` changes *  identity (detection results are stable per analysis), discarding edits — *  fresh detection means a fresh starting set. */export function useAuthMethodList(seeds: readonly AuthMethodSeed[]): AuthMethodListState {  const [rows, setRows] = useState<readonly AuthMethodRow[]>([]);  const seededFromRef = useRef<readonly AuthMethodSeed[] | null>(null);  useEffect(() => {    if (seededFromRef.current === seeds) return;    seededFromRef.current = seeds;    setRows(      seeds.map(        (seed: AuthMethodSeed): AuthMethodRow => ({          value: seed.value,          seeded: true,          ...(seed.slug !== undefined ? { seedSlug: seed.slug } : {}),          ...(seed.label !== undefined ? { seedLabel: seed.label } : {}),        }),      ),    );  }, [seeds]);   const setRowAt = useCallback((index: number, next: AuthTemplateEditorValue) => {    setRows((current: readonly AuthMethodRow[]) =>      current.map((row: AuthMethodRow, i: number) => (i === index ? { ...row, value: next } : row)),    );  }, []);   const removeRowAt = useCallback((index: number) => {    setRows((current: readonly AuthMethodRow[]) =>      current.filter((_row: AuthMethodRow, i: number) => i !== index),    );  }, []);   const addRow = useCallback(() => {    setRows((current: readonly AuthMethodRow[]) => [      ...current,      { value: emptyApiKeyValue(), seeded: false },    ]);  }, []);   return { rows, setRowAt, removeRowAt, addRow };} export interface AuthMethodListEditorProps {  readonly list: AuthMethodListState;  readonly title?: string;  /** Shown when the list is empty (e.g. "No authentication detected. …"). */  readonly emptyHint?: string;  /** Shown under the list when at least one row exists. */  readonly footerHint?: string;  /** Per-row editor restrictions — see `AuthTemplateEditorProps`. */  readonly allowedKinds?: readonly AuthTemplateEditorKind[];  readonly presets?: readonly AuthTemplateEditorPreset[];  readonly oauthMetadata?: "editable" | "discovered";} export function AuthMethodListEditor(props: AuthMethodListEditorProps) {  const { list, allowedKinds, presets, oauthMetadata } = props;  return (    <section className="space-y-3">      <div className="flex items-center justify-between gap-3">        <FieldLabel>{props.title ?? "How does this API authenticate?"}</FieldLabel>        <Button type="button" variant="outline" size="sm" onClick={list.addRow}>          <PlusIcon />          Add method        </Button>      </div>      {list.rows.length === 0 ? (        props.emptyHint ? (          <p className="text-[11px] text-muted-foreground">{props.emptyHint}</p>        ) : null      ) : (        <div className="flex flex-col gap-3">          {list.rows.map((row: AuthMethodRow, index: number) => {            // A row seeded from detection is the spec's own auth declaration:            // it's IMMUTABLE here. We render it read-only (no kind selector, no            // editable fields) so a user can't silently retype the spec's            // method into something nothing backs (e.g. flipping a Bearer-token            // API to OAuth with empty endpoints). The escape hatch is to remove            // the row and add a custom one. Manually added rows (no seed) get            // the full editor.            const detected = row.seeded;            return (              <div                key={index}                className="space-y-2 rounded-lg border border-border/60 bg-muted/20 p-3"              >                <div className="flex items-center justify-between">                  <span className="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">                    {detected ? <LockIcon className="size-3 shrink-0" aria-hidden /> : null}                    {/* Named by what it is. "Method 1" told the reader nothing                        except that there might be a Method 2, and next to a                        masked value it read as a locked credential field —                        people tried to paste their key into it. */}                    <span>{detected ? detectedMethodTitle(row) : `Method ${index + 1}`}</span>                  </span>                  <Button                    type="button"                    variant="ghost"                    size="icon-sm"                    aria-label="Remove method"                    className="text-muted-foreground hover:text-foreground"                    onClick={() => list.removeRowAt(index)}                  >                    <XIcon />                  </Button>                </div>                {detected ? (                  <DetectedMethodSummary value={row.value} oauthMetadata={oauthMetadata} />                ) : (                  <AuthTemplateEditor                    value={row.value}                    onChange={(next: AuthTemplateEditorValue) => list.setRowAt(index, next)}                    {...(allowedKinds ? { allowedKinds } : {})}                    {...(presets ? { presets } : {})}                    {...(oauthMetadata ? { oauthMetadata } : {})}                  />                )}              </div>            );          })}        </div>      )}      {list.rows.length > 0 && props.footerHint ? (        <p className="text-[11px] text-muted-foreground">{props.footerHint}</p>      ) : null}    </section>  );} /** What a spec-detected method should be called: the credential it wants, plus *  the spec's own name for it when there is more than one to tell apart. */function detectedMethodTitle(row: AuthMethodRow): string {  const kind = row.value.kind;  const base = kind === "oauth" ? "OAuth" : kind === "apikey" ? "API key" : "No authentication";  return row.seedLabel ? `${base} · ${row.seedLabel}` : base;} /** One read-only `label   value` line, mono value, for the detected summary. */function SpecField(props: { readonly label: string; readonly value: string }) {  return (    <div className="flex gap-2 text-xs">      <span className="w-20 shrink-0 text-muted-foreground">{props.label}</span>      <span className="break-all font-mono text-foreground/80">{props.value}</span>    </div>  );} /** Read-only view of a spec-detected method: shows what the spec declared *  (placements / OAuth endpoints) as a DISABLED, non-interactive block. The *  detected method is immutable here, so the summary is styled like a disabled *  field (muted, not-allowed cursor, text not selectable) to communicate that *  plainly. The only action is to remove the row (the header's X) and add a *  custom method to override. */function DetectedMethodSummary(props: {  readonly value: AuthTemplateEditorValue;  readonly oauthMetadata?: "editable" | "discovered";}) {  const { value, oauthMetadata } = props;  // Name the auth kind explicitly: a detection label like MCP's "Detected"  // doesn't say whether it's OAuth or an API key, so surface it here.  return (    <div className="space-y-2">      <div        aria-disabled        className="select-none space-y-1 rounded-md border border-border/60 bg-muted/40 px-3 py-2.5 text-muted-foreground"      >        {value.kind === "none" && (          <p className="text-xs">No credential — tools are callable without an account.</p>        )}         {value.kind === "apikey" &&          (value.placements.length > 0 ? (            <div className="space-y-1">              <p className="text-xs">This API takes a key, sent as:</p>              <div className="flex flex-wrap gap-x-4 gap-y-1">                {value.placements.map((placement, i: number) => (                  <PlacementLine key={i} placement={placement} />                ))}              </div>            </div>          ) : null)}         {value.kind === "oauth" &&          (oauthMetadata === "discovered" ? (            <p className="text-xs">              OAuth metadata is discovered from this server when you connect an account.            </p>          ) : (            <div className="space-y-1">              {value.authorizationUrl ? (                <SpecField label="Authorize" value={value.authorizationUrl} />              ) : null}              {value.tokenUrl ? <SpecField label="Token" value={value.tokenUrl} /> : null}              {value.scopes.length > 0 ? (                <SpecField label="Scopes" value={value.scopes.join(", ")} />              ) : null}            </div>          ))}      </div>       {/* The question this screen used to leave open. Nothing on the add form          takes a credential; the key is entered once the integration exists,          from its own page. Saying so is the difference between a confident          next click and hunting for a field that is not here. */}      <p className="text-[11px] text-muted-foreground">        {/* "Declared by this API", not "read from the spec": GraphQL surfaces            have no spec — their methods arrive from the registry record — and            the claim that matters is the same either way. */}        {value.kind === "oauth"          ? "Declared by this API. You'll sign in after adding this integration."          : "Declared by this API. You'll enter your key after adding this integration."}      </p>    </div>  );}