From cfdbf19bed6117ad4212c2231b461c946ce0885b Mon Sep 17 00:00:00 2001 From: mrzmyr Date: Sat, 11 Apr 2026 12:30:29 +0200 Subject: [PATCH 1/4] feat(react): add and primitives MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two composable primitives shared across source-plugin Add forms: - + presets prop — headers list with quick-add picker (replaces the closed AuthenticationSection). - + useSourceIdentity hook — display-name + auto-derived namespace, with namespace detaching on manual edit. --- packages/react/src/plugins/headers-list.tsx | 167 ++++++++++++ .../react/src/plugins/secret-header-auth.tsx | 257 ++++++++---------- .../react/src/plugins/source-identity.tsx | 159 +++++++++++ 3 files changed, 441 insertions(+), 142 deletions(-) create mode 100644 packages/react/src/plugins/headers-list.tsx create mode 100644 packages/react/src/plugins/source-identity.tsx diff --git a/packages/react/src/plugins/headers-list.tsx b/packages/react/src/plugins/headers-list.tsx new file mode 100644 index 0000000000..b3a3220dd7 --- /dev/null +++ b/packages/react/src/plugins/headers-list.tsx @@ -0,0 +1,167 @@ +import { useState, type ReactNode } from "react"; +import { PlusIcon } from "lucide-react"; + +import { Button } from "../components/button"; +import { + CardStack, + CardStackContent, + CardStackEmpty, + CardStackEntry, +} from "../components/card-stack"; +import { + defaultHeaderAuthPresets, + type HeaderAuthPreset, + type HeaderState, + SecretHeaderAuthRow, +} from "./secret-header-auth"; +import type { SecretPickerSecret } from "./secret-picker"; + +export interface HeadersListProps { + readonly headers: readonly HeaderState[]; + readonly onHeadersChange: (headers: HeaderState[]) => void; + readonly existingSecrets?: readonly SecretPickerSecret[]; + /** Presets offered in the quick-add picker. Defaults to `defaultHeaderAuthPresets`. */ + readonly presets?: readonly HeaderAuthPreset[]; + /** When true, only allow a single header (hide add button, disable remove). */ + readonly singleHeader?: boolean; + /** Text shown in the empty state. */ + readonly emptyLabel?: ReactNode; +} + +export function HeadersList({ + headers, + onHeadersChange, + existingSecrets = [], + presets = defaultHeaderAuthPresets, + singleHeader = false, + emptyLabel = "No headers", +}: HeadersListProps) { + const [picking, setPicking] = useState(false); + const canAddMore = !singleHeader || headers.length === 0; + + const addHeaderFromPreset = (preset: HeaderAuthPreset) => { + onHeadersChange([ + ...headers, + { + name: preset.name, + prefix: preset.prefix, + presetKey: preset.key, + secretId: null, + }, + ]); + setPicking(false); + }; + + const updateHeader = ( + index: number, + update: Partial<{ + name: string; + secretId: string | null; + prefix?: string; + presetKey?: string; + }>, + ) => { + onHeadersChange( + headers.map((entry, i) => (i === index ? { ...entry, ...update } : entry)), + ); + }; + + const removeHeader = (index: number) => { + onHeadersChange(headers.filter((_, i) => i !== index)); + }; + + return ( + + + {picking ? ( + setPicking(false)} + /> + ) : headers.length === 0 ? ( + canAddMore ? ( + {emptyLabel}} onClick={() => setPicking(true)} /> + ) : ( + + {emptyLabel} + + ) + ) : ( + <> + {headers.map((header, index) => ( + updateHeader(index, update)} + onSelectSecret={(secretId) => updateHeader(index, { secretId })} + onRemove={singleHeader ? undefined : () => removeHeader(index)} + existingSecrets={existingSecrets} + /> + ))} + {canAddMore && setPicking(true)} />} + + )} + + + ); +} + +interface AddHeaderRowProps { + readonly onClick: () => void; + readonly leading?: ReactNode; +} + +function AddHeaderRow({ onClick, leading }: AddHeaderRowProps) { + return ( + // oxlint-disable-next-line react/forbid-elements + + ); +} + +interface HeaderPresetPickerProps { + readonly presets: readonly HeaderAuthPreset[]; + readonly onPick: (preset: HeaderAuthPreset) => void; + readonly onCancel: () => void; +} + +function HeaderPresetPicker({ presets, onPick, onCancel }: HeaderPresetPickerProps) { + return ( + + {presets.map((preset) => ( + + ))} + + + ); +} diff --git a/packages/react/src/plugins/secret-header-auth.tsx b/packages/react/src/plugins/secret-header-auth.tsx index e62827931e..ae0dac751e 100644 --- a/packages/react/src/plugins/secret-header-auth.tsx +++ b/packages/react/src/plugins/secret-header-auth.tsx @@ -1,11 +1,11 @@ -import { useState } from "react"; +import { useId, useState } from "react"; import { useAtomRefresh, useAtomSet } from "@effect-atom/atom-react"; import { secretsAtom, setSecret, resolveSecret } from "../api/atoms"; import { useScope } from "../api/scope-context"; import { Button } from "../components/button"; +import { Field, FieldError, FieldGroup, FieldLabel } from "../components/field"; import { Input } from "../components/input"; -import { Label } from "../components/label"; import { Spinner } from "../components/spinner"; import { SecretPicker, type SecretPickerSecret } from "./secret-picker"; import { SecretId } from "@executor/sdk"; @@ -74,6 +74,9 @@ function InlineCreateSecret(props: { const scopeId = useScope(); const doSet = useAtomSet(setSecret, { mode: "promise" }); const refreshSecrets = useAtomRefresh(secretsAtom(scopeId)); + const secretIdInputId = useId(); + const secretNameInputId = useId(); + const secretValueInputId = useId(); const handleSave = async () => { if (!secretId.trim() || !secretValue.trim()) return; @@ -98,53 +101,55 @@ function InlineCreateSecret(props: { }; return ( -
-

New secret

-
-
- - setSecretId((e.target as HTMLInputElement).value)} - placeholder="my-api-token" - className="h-8 text-sm font-mono" - /> -
-
- - setSecretName((e.target as HTMLInputElement).value)} - placeholder="API Token" - className="h-8 text-sm" - /> -
-
-
- -
- setSecretValue((e.target as HTMLInputElement).value)} - placeholder="paste your token or key…" - className="h-8 pr-8 text-xs font-mono" - /> - +
+

New secret

+ +
+ + ID + setSecretId((e.target as HTMLInputElement).value)} + placeholder="my-api-token" + className="font-mono" + /> + + + Label + setSecretName((e.target as HTMLInputElement).value)} + placeholder="API Token" + /> +
-
- {error &&

{error}

} + + Value +
+ setSecretValue((e.target as HTMLInputElement).value)} + placeholder="paste your token or key…" + className="pr-9 font-mono" + /> + +
+ {error && {error}} +
+
- ))} -
- - {presetKey !== undefined && ( -
-
- - - onChange({ - name: (e.target as HTMLInputElement).value, - prefix, - presetKey: isCustom ? "custom" : presetKey, - }) - } - placeholder="Authorization" - className="h-8 text-sm font-mono" - /> -
-
- - - onChange({ - name, - prefix: (e.target as HTMLInputElement).value || undefined, - presetKey: isCustom ? "custom" : presetKey, - }) - } - placeholder="Bearer " - className="h-8 text-sm font-mono" - /> -
-
- )} + placeholder="Authorization" + className="font-mono" + /> + + + + Prefix (optional) + + + onChange({ + name, + prefix: (e.target as HTMLInputElement).value || undefined, + presetKey: isCustom ? "custom" : presetKey, + }) + } + placeholder="Bearer " + className="font-mono" + /> + + - {presetKey !== undefined && name.trim() && ( -
-
- -
- +
+
+
- )} + +
{secretId && name.trim() && ( diff --git a/packages/react/src/plugins/source-identity.tsx b/packages/react/src/plugins/source-identity.tsx new file mode 100644 index 0000000000..49187bfec4 --- /dev/null +++ b/packages/react/src/plugins/source-identity.tsx @@ -0,0 +1,159 @@ +import { useCallback, useState } from "react"; +import { parse } from "tldts"; + +import { + CardStack, + CardStackContent, + CardStackEntryField, +} from "../components/card-stack"; +import { Input } from "../components/input"; + +// --------------------------------------------------------------------------- +// Slug helper +// --------------------------------------------------------------------------- + +/** + * Normalizes a display name into a valid namespace identifier: lowercase + * snake_case, only `[a-z0-9_]`, no leading/trailing underscores. Produces + * strings that are safe to use as TypeScript/tool-name prefixes. + */ +export function slugifyNamespace(input: string): string { + return input + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); +} + +/** + * Derives a display-name candidate from a URL by extracting its apex domain + * label (e.g. `https://api.shopify.com/graphql` → `"Shopify"`) and + * title-casing it. Returns `null` if the URL has no parseable domain. + */ +export function displayNameFromUrl(url: string): string | null { + const trimmed = url.trim(); + if (!trimmed) return null; + const parsed = parse(trimmed); + const label = parsed.domainWithoutSuffix; + if (!label) return null; + return label.charAt(0).toUpperCase() + label.slice(1); +} + +// --------------------------------------------------------------------------- +// Hook — owns the name + namespace state with namespace auto-derivation +// --------------------------------------------------------------------------- + +export interface SourceIdentity { + /** Display name — the user's override if they've typed one, otherwise the fallback. */ + readonly name: string; + /** Namespace — the user's override if they've typed one, otherwise slugified from `name`. */ + readonly namespace: string; + readonly setName: (name: string) => void; + readonly setNamespace: (namespace: string) => void; + /** Clears any user overrides so both fields return to deriving from the fallback. */ + readonly reset: () => void; +} + +export interface UseSourceIdentityOptions { + /** + * Fallback display name — used when the user hasn't typed one. Pass a + * value computed from the caller's reactive state (probe result, URL + * apex domain, template default, etc.) and it'll flow through to `name` + * automatically. + */ + readonly fallbackName?: string; + /** Fallback namespace — defaults to `slugifyNamespace(fallbackName ?? "")`. */ + readonly fallbackNamespace?: string; +} + +/** + * Manages a display name and a derived namespace. Both fields are pure + * derived state: the user's `setName` / `setNamespace` call stores an + * override, otherwise the hook returns the caller-supplied fallback + * (passed fresh on every render). Call `reset()` to drop overrides. + */ +export function useSourceIdentity(options?: UseSourceIdentityOptions): SourceIdentity { + const [nameOverride, setNameOverride] = useState(null); + const [namespaceOverride, setNamespaceOverride] = useState(null); + + const fallbackName = options?.fallbackName ?? ""; + const name = nameOverride ?? fallbackName; + const fallbackNamespace = options?.fallbackNamespace ?? slugifyNamespace(name); + const namespace = namespaceOverride ?? fallbackNamespace; + + const setName = useCallback((next: string) => { + setNameOverride(next); + }, []); + + const setNamespace = useCallback((next: string) => { + setNamespaceOverride(slugifyNamespace(next)); + }, []); + + const reset = useCallback(() => { + setNameOverride(null); + setNamespaceOverride(null); + }, []); + + return { name, namespace, setName, setNamespace, reset }; +} + +// --------------------------------------------------------------------------- +// UI — two fields, wrapped in a shared CardStack +// --------------------------------------------------------------------------- + +export interface SourceIdentityFieldsProps { + readonly identity: SourceIdentity; + readonly namePlaceholder?: string; + readonly namespacePlaceholder?: string; + readonly nameLabel?: string; + readonly namespaceHint?: string; + /** + * When true, the namespace field is rendered disabled — useful on Edit + * forms, where the namespace is the source's identity and changing it + * would require a delete + recreate flow. + */ + readonly namespaceReadOnly?: boolean; +} + +export function SourceIdentityFields({ + identity, + namePlaceholder = "e.g. Sentry API", + namespacePlaceholder = "sentry_api", + nameLabel = "Display Name", + namespaceHint, + namespaceReadOnly = false, +}: SourceIdentityFieldsProps) { + const effectiveNamespaceHint = + namespaceHint ?? + (namespaceReadOnly + ? "The namespace is part of the source's identity and cannot be changed." + : "Prefix for the tool names. Auto-derived from the display name."); + + return ( + + + + identity.setName((e.target as HTMLInputElement).value)} + placeholder={namePlaceholder} + className="text-sm" + /> + + + identity.setNamespace((e.target as HTMLInputElement).value)} + placeholder={namespacePlaceholder} + className="font-mono text-sm" + disabled={namespaceReadOnly} + /> + + + + ); +} From 4073dcb2286e5e90bd6174b28523ad14c050c4bf Mon Sep 17 00:00:00 2001 From: mrzmyr Date: Sat, 11 Apr 2026 12:31:27 +0200 Subject: [PATCH 2/4] refactor(mcp): adopt in Add/Edit source forms Replaces the hand-rolled auth/URL/headers UI with + + inline OAuth content, for the save/cancel row, and +