// ---------------------------------------------------------------------------// 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> );}
// ---------------------------------------------------------------------------
// 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>
);
}