234 lines · 9.0 KB · TypeScript
RawDownload
import { type Placement } from "../lib/auth-placements";import { Button } from "./button";import { FilterTabs } from "./filter-tabs";import { Input } from "./input";import { Label } from "./label";import { PlacementEditor } from "./placement-editor"; // ---------------------------------------------------------------------------// Auth-template editor — the single "How does this API authenticate?" editor.//// Add-time only: this declares an integration's auth TEMPLATE (where the// credential goes / which OAuth endpoints), never a secret and never a network// call. Plugin-agnostic — each plugin owns the mapping from this generic value// to its own stored wire template (mirroring the `Placement` model and the// per-plugin `auth-method-config` converters). One editor, three plugins.//// Tabs://  - `none`   — open / no credential.//  - `apikey` — one secret sent at one or more PLACEMENTS (header / query),//               edited through the shared `PlacementEditor`. When `presets`//               are supplied (OpenAPI spec-detected methods) a pill row offers//               editable defaults: clicking a pill pre-fills the value.//  - `oauth`  — provider authorize/token URLs + scopes. No client id/secret —//               that lives on the OAuth client, registered at connect time.// --------------------------------------------------------------------------- export type AuthTemplateEditorKind = "none" | "apikey" | "oauth"; export type AuthTemplateEditorValue =  | { readonly kind: "none" }  | { readonly kind: "apikey"; readonly placements: readonly Placement[] }  | {      readonly kind: "oauth";      readonly authorizationUrl: string;      readonly tokenUrl: string;      readonly scopes: readonly string[];    }; export interface AuthTemplateEditorPreset {  /** Pill text, e.g. "Bearer token", "OAuth2". */  readonly label: string;  /** Clicking the pill applies this value (an editable default). */  readonly value: AuthTemplateEditorValue;} const DEFAULT_ALLOWED: readonly AuthTemplateEditorKind[] = ["none", "apikey", "oauth"]; const TAB_LABELS: Readonly<Record<AuthTemplateEditorKind, string>> = {  none: "None",  apikey: "API key",  oauth: "OAuth",}; /** A fresh, empty header placement for the apiKey tab's first row. */export const emptyApiKeyValue = (): AuthTemplateEditorValue => ({  kind: "apikey",  placements: [{ carrier: "header", name: "Authorization", prefix: "" }],}); export const emptyOAuthValue = (): AuthTemplateEditorValue => ({  kind: "oauth",  authorizationUrl: "",  tokenUrl: "",  scopes: [],}); /** The empty default value for a given tab kind. Switching tabs replaces the *  value with this so the editor below always has the right shape to edit. */export const emptyValueForKind = (kind: AuthTemplateEditorKind): AuthTemplateEditorValue => {  if (kind === "none") return { kind: "none" };  if (kind === "apikey") return emptyApiKeyValue();  return emptyOAuthValue();}; /** Parse a comma-separated scope string into a trimmed, non-empty list. */export const parseScopes = (raw: string): readonly string[] =>  raw    .split(",")    .map((scope: string) => scope.trim())    .filter((scope: string) => scope.length > 0); export interface AuthTemplateEditorProps {  readonly value: AuthTemplateEditorValue;  readonly onChange: (value: AuthTemplateEditorValue) => void;  /** Restrict which tabs render. Default `["none","apikey","oauth"]`. MCP   *  OAuth-only servers pass `["oauth"]`; GraphQL passes `["none","apikey"]`. */  readonly allowedKinds?: readonly AuthTemplateEditorKind[];  /** Spec-detected presets (OpenAPI) → editable-default pills on the apiKey/OAuth   *  tabs. Clicking a pill applies its value as an editable default. */  readonly presets?: readonly AuthTemplateEditorPreset[];  /** Some integrations, notably MCP, discover OAuth metadata from the server at   *  connection time. In that case the add form should declare OAuth without   *  editable provider URLs or scopes. */  readonly oauthMetadata?: "editable" | "discovered";} export function AuthTemplateEditor(props: AuthTemplateEditorProps) {  const {    value,    onChange,    allowedKinds = DEFAULT_ALLOWED,    presets,    oauthMetadata = "editable",  } = props;   const tabs = allowedKinds.map((kind: AuthTemplateEditorKind) => ({    value: kind,    label: TAB_LABELS[kind],  }));   // Switching tab replaces the value with an empty default of that kind (so the  // editor below has the right shape to edit). Re-selecting the current tab is a  // no-op (it would otherwise clobber the user's in-progress edits).  const handleTab = (next: AuthTemplateEditorKind): void => {    if (next === value.kind) return;    onChange(emptyValueForKind(next));  };   // Presets that match the active tab's kind — rendered as editable-default pills.  const tabPresets = (presets ?? []).filter(    (preset: AuthTemplateEditorPreset) => preset.value.kind === value.kind,  );   return (    <div className="space-y-3">      {tabs.length > 1 && (        <FilterTabs<AuthTemplateEditorKind> tabs={tabs} value={value.kind} onChange={handleTab} />      )}       {value.kind === "none" && (        <p className="text-xs text-muted-foreground">          No credential — this integration is open and tools are callable without an account.        </p>      )}       {value.kind === "apikey" && (        <div className="space-y-2.5">          {tabPresets.length > 0 && (            <div className="flex flex-wrap gap-1.5">              {tabPresets.map((preset: AuthTemplateEditorPreset, index: number) => (                <Button                  key={`${preset.label}-${index}`}                  type="button"                  variant="outline"                  size="sm"                  className="rounded-full"                  onClick={() => onChange(preset.value)}                >                  {preset.label}                </Button>              ))}            </div>          )}          <PlacementEditor            placements={value.placements}            onChange={(placements: Placement[]) => onChange({ kind: "apikey", placements })}          />        </div>      )}       {value.kind === "oauth" && (        <div className="space-y-3">          {tabPresets.length > 0 && (            <div className="flex flex-wrap gap-1.5">              {tabPresets.map((preset: AuthTemplateEditorPreset, index: number) => (                <Button                  key={`${preset.label}-${index}`}                  type="button"                  variant="outline"                  size="sm"                  className="rounded-full"                  onClick={() => onChange(preset.value)}                >                  {preset.label}                </Button>              ))}            </div>          )}          {oauthMetadata === "discovered" ? (            <p className="rounded-lg border border-border/60 bg-muted/20 px-3 py-2 text-xs text-muted-foreground">              OAuth metadata is discovered from this server when you connect an account.            </p>          ) : (            <>              <div className="space-y-2">                <Label htmlFor="auth-authorization-url" className="text-xs text-muted-foreground">                  Authorization URL                </Label>                <Input                  id="auth-authorization-url"                  placeholder="https://provider.example.com/oauth/authorize"                  value={value.authorizationUrl}                  onChange={(e: React.ChangeEvent<HTMLInputElement>) =>                    onChange({ ...value, authorizationUrl: e.target.value })                  }                  className="font-mono text-sm"                />              </div>              <div className="space-y-2">                <Label htmlFor="auth-token-url" className="text-xs text-muted-foreground">                  Token URL                </Label>                <Input                  id="auth-token-url"                  placeholder="https://provider.example.com/oauth/token"                  value={value.tokenUrl}                  onChange={(e: React.ChangeEvent<HTMLInputElement>) =>                    onChange({ ...value, tokenUrl: e.target.value })                  }                  className="font-mono text-sm"                />              </div>              <div className="space-y-2">                <Label htmlFor="auth-scopes" className="text-xs text-muted-foreground">                  Scopes                  <span className="font-normal text-muted-foreground/70">comma-separated</span>                </Label>                <Input                  id="auth-scopes"                  placeholder="read, write"                  value={value.scopes.join(", ")}                  onChange={(e: React.ChangeEvent<HTMLInputElement>) =>                    onChange({ ...value, scopes: parseScopes(e.target.value) })                  }                  className="font-mono text-sm"                />              </div>            </>          )}        </div>      )}    </div>  );}