From 6dec2e1f888446551970b20e6062576f2ed3fb08 Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Wed, 13 May 2026 23:17:44 -0700 Subject: [PATCH] Refactor HTTP credential UI composition --- .../src/react/AddGoogleDiscoverySource.tsx | 2 +- .../graphql/src/react/AddGraphqlSource.tsx | 9 +- .../graphql/src/react/EditGraphqlSource.tsx | 9 +- .../plugins/mcp/src/react/AddMcpSource.tsx | 287 +------ .../plugins/mcp/src/react/EditMcpSource.tsx | 9 +- .../openapi/src/react/AddOpenApiSource.tsx | 244 +++--- packages/react/src/pages/secrets.tsx | 2 +- .../src/plugins/creatable-secret-picker.tsx | 173 ++++ .../src/plugins/credential-bindings.test.ts | 13 + .../react/src/plugins/credential-bindings.tsx | 28 +- .../src/plugins/credential-slot-bindings.tsx | 2 +- packages/react/src/plugins/headers-list.tsx | 225 ----- .../src/plugins/http-credentials.test.ts | 118 +++ .../react/src/plugins/http-credentials.tsx | 791 +++++++++++++++--- .../react/src/plugins/secret-header-auth.tsx | 541 ------------ ...er-auth.test.ts => secret-helpers.test.ts} | 0 .../src/plugins/source-oauth-connection.tsx | 128 ++- 17 files changed, 1256 insertions(+), 1325 deletions(-) create mode 100644 packages/react/src/plugins/creatable-secret-picker.tsx delete mode 100644 packages/react/src/plugins/headers-list.tsx create mode 100644 packages/react/src/plugins/http-credentials.test.ts delete mode 100644 packages/react/src/plugins/secret-header-auth.tsx rename packages/react/src/plugins/{secret-header-auth.test.ts => secret-helpers.test.ts} (100%) diff --git a/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx b/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx index 6d9491f53..ee6ab4071 100644 --- a/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx +++ b/packages/plugins/google-discovery/src/react/AddGoogleDiscoverySource.tsx @@ -7,7 +7,7 @@ import * as Schema from "effect/Schema"; import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { useScope, useUserScope } from "@executor-js/react/api/scope-context"; import type { SecretPickerSecret } from "@executor-js/react/plugins/secret-picker"; -import { CreatableSecretPicker } from "@executor-js/react/plugins/secret-header-auth"; +import { CreatableSecretPicker } from "@executor-js/react/plugins/creatable-secret-picker"; import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets"; import type { ScopeId } from "@executor-js/sdk"; import { Badge } from "@executor-js/react/components/badge"; diff --git a/packages/plugins/graphql/src/react/AddGraphqlSource.tsx b/packages/plugins/graphql/src/react/AddGraphqlSource.tsx index 8d2ea5e97..3e076f693 100644 --- a/packages/plugins/graphql/src/react/AddGraphqlSource.tsx +++ b/packages/plugins/graphql/src/react/AddGraphqlSource.tsx @@ -7,7 +7,7 @@ import * as Schema from "effect/Schema"; import { useScope } from "@executor-js/react/api/scope-context"; import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { - HttpCredentialsEditor, + HttpCredentials, httpCredentialsValid, serializeScopedHttpCredentials, serializeHttpCredentials, @@ -177,7 +177,7 @@ export default function AddGraphqlSource(props: { - + > + + + {/* Temporarily hidden while we revisit GraphQL OAuth discovery and UX. */} diff --git a/packages/plugins/graphql/src/react/EditGraphqlSource.tsx b/packages/plugins/graphql/src/react/EditGraphqlSource.tsx index a57cc5acd..199f23742 100644 --- a/packages/plugins/graphql/src/react/EditGraphqlSource.tsx +++ b/packages/plugins/graphql/src/react/EditGraphqlSource.tsx @@ -12,7 +12,7 @@ import { useScope, useScopeStack } from "@executor-js/react/api/scope-context"; import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets"; import { - HttpCredentialsEditor, + HttpCredentials, serializeHttpCredentials, serializeScopedHttpCredentials, type HttpCredentialsState, @@ -191,7 +191,7 @@ function EditForm(props: { namespaceReadOnly /> - + > + + + {/* Temporarily hidden while we revisit GraphQL OAuth discovery and UX. */} diff --git a/packages/plugins/mcp/src/react/AddMcpSource.tsx b/packages/plugins/mcp/src/react/AddMcpSource.tsx index 043ce4395..36153bc57 100644 --- a/packages/plugins/mcp/src/react/AddMcpSource.tsx +++ b/packages/plugins/mcp/src/react/AddMcpSource.tsx @@ -1,4 +1,4 @@ -import { useReducer, useCallback, useEffect, useRef, useState, type ReactNode } from "react"; +import { useReducer, useCallback, useEffect, useRef, useState } from "react"; import { useAtomSet } from "@effect/atom-react"; import * as Exit from "effect/Exit"; import * as Match from "effect/Match"; @@ -10,20 +10,18 @@ import { Button } from "@executor-js/react/components/button"; import { CardStack, CardStackContent, - CardStackEntry, CardStackEntryField, } from "@executor-js/react/components/card-stack"; import { FieldLabel } from "@executor-js/react/components/field"; import { FilterTabs } from "@executor-js/react/components/filter-tabs"; import { FloatActions } from "@executor-js/react/components/float-actions"; import { Input } from "@executor-js/react/components/input"; -import { Label } from "@executor-js/react/components/label"; import { Spinner } from "@executor-js/react/components/spinner"; import { Textarea } from "@executor-js/react/components/textarea"; import { emptyHttpCredentials, + HttpCredentials, httpCredentialsValid, - HttpCredentialsEditor, serializeScopedHttpCredentials, serializeHttpCredentials, } from "@executor-js/react/plugins/http-credentials"; @@ -40,11 +38,8 @@ import { useOAuthPopupFlow, type OAuthCompletionPayload, } from "@executor-js/react/plugins/oauth-sign-in"; -import { - CredentialControlField, - CredentialUsageRow, - useCredentialTargetScope, -} from "@executor-js/react/plugins/credential-target-scope"; +import { useCredentialTargetScope } from "@executor-js/react/plugins/credential-target-scope"; +import { OAuthConnectionControl } from "@executor-js/react/plugins/source-oauth-connection"; type RemoteAuthMode = "none" | "oauth2"; import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; @@ -87,11 +82,6 @@ type ProbeResult = { serverName: string | null; }; -type PlainHeader = { - name: string; - value: string; -}; - type State = | { step: "url"; url: string } | { step: "probing"; url: string; probe: ProbeResult | null } @@ -306,7 +296,6 @@ export default function AddMcpSource(props: { }); const [remoteAuthMode, setRemoteAuthMode] = useState("none"); - const [remoteHeaders, setRemoteHeaders] = useState([]); const [remoteCredentials, setRemoteCredentials] = useState(() => emptyHttpCredentials()); const probe = "probe" in state ? state.probe : null; @@ -321,18 +310,10 @@ export default function AddMcpSource(props: { const isOAuthBusy = state.step === "oauth-starting" || state.step === "oauth-waiting" || oauth.busy; const canUseNone = probe?.requiresOAuth !== true || probe.supportsDynamicRegistration === false; - const remoteHeadersComplete = remoteHeaders.every( - (header) => header.name.trim() && header.value.trim(), - ); const remoteCredentialsComplete = httpCredentialsValid(remoteCredentials); const authReady = remoteAuthMode === "none" ? canUseNone : tokens !== null; const canAdd = - Boolean(probe) && - authReady && - remoteHeadersComplete && - remoteCredentialsComplete && - !isAdding && - !isOAuthBusy; + Boolean(probe) && authReady && remoteCredentialsComplete && !isAdding && !isOAuthBusy; // Probe failures are shown inline on the URL field; other failures // (OAuth start, add source) render in the bottom error block. const probeError = state.step === "error" && state.probe === null ? state.error : null; @@ -442,19 +423,10 @@ export default function AddMcpSource(props: { connectionSlot: MCP_OAUTH_CONNECTION_SLOT, } : { kind: "none" as const }; - const headers = Object.fromEntries( - remoteHeaders - .map((header) => [header.name.trim(), header.value.trim()] as const) - .filter(([name, value]) => name && value), - ); const credentials = serializeScopedHttpCredentials( remoteCredentials, requestCredentialTargetScope, ); - const remoteRequestHeaders: Record = { - ...headers, - ...credentials.headers, - }; const displayName = remoteIdentity.name.trim() || probe.serverName || probe.name; const slugNamespace = slugifyNamespace(remoteIdentity.namespace); const exit = await doAdd({ @@ -470,7 +442,9 @@ export default function AddMcpSource(props: { remoteAuthMode === "oauth2" && tokens ? oauthCredentialTargetScope : requestCredentialTargetScope, - ...(Object.keys(remoteRequestHeaders).length > 0 ? { headers: remoteRequestHeaders } : {}), + ...(Object.keys(credentials.headers).length > 0 + ? { headers: credentials.headers as Record } + : {}), ...(Object.keys(credentials.queryParams).length > 0 ? { queryParams: credentials.queryParams } : {}), @@ -488,7 +462,6 @@ export default function AddMcpSource(props: { }, [ probe, remoteAuthMode, - remoteHeaders, remoteCredentials, remoteIdentity, tokens, @@ -606,7 +579,7 @@ export default function AddMcpSource(props: { onRetry={handleProbe} /> - + > + + + {/* Authentication */} {probe && ( @@ -640,187 +612,42 @@ export default function AddMcpSource(props: { {remoteAuthMode === "oauth2" && ( - { + { setOAuthCredentialTargetScope(targetScope); dispatch({ type: "oauth-reset" }); }} - label="Connection saved to" - help="Choose who can use the OAuth connection." - > - - {!tokens && - state.step === "probed" && - (probe.supportsDynamicRegistration ? ( - - Sign in - - ) : ( - - This server requires OAuth, but its authorization server does not support - dynamic client registration. Use request headers with a bearer token, or - save the source and connect a supported OAuth connection later. - - ))} - - {!tokens && state.step === "oauth-starting" && ( - - - - Starting authorization... - - - )} - - {!tokens && state.step === "oauth-waiting" && ( - - - - Waiting for authorization... - - - Cancel - - - )} - - {tokens && ( - - - - - - Authenticated - - - )} - - - )} - - )} - - {/* Additional headers */} - {probe && ( - - - Additional headers - - Plaintext headers sent with every request. Use request headers above for - secret-backed values. - - - - - - {remoteHeaders.length === 0 ? ( - No headers} - onClick={() => - setRemoteHeaders((headers) => [...headers, { name: "", value: "" }]) - } - /> - ) : ( - <> - {remoteHeaders.map((header, index) => ( - - - - Header - - - setRemoteHeaders((headers) => - headers.filter((_, headerIndex) => headerIndex !== index), - ) + label="Connect via OAuth" + status={ + tokens + ? { kind: "connected", label: "Authenticated" } + : state.step === "oauth-starting" + ? { kind: "busy", label: "Starting authorization..." } + : state.step === "oauth-waiting" + ? { kind: "busy", label: "Waiting for authorization..." } + : state.step === "probed" && !probe.supportsDynamicRegistration + ? { + kind: "blocked", + label: + "This server requires OAuth, but its authorization server does not support dynamic client registration. Use request headers with a bearer token, or save the source and connect a supported OAuth connection later.", } - > - Remove - - - - - - Name - - - setRemoteHeaders((headers) => - headers.map((current, headerIndex) => - headerIndex === index - ? { - ...current, - name: (event.target as HTMLInputElement).value, - } - : current, - ), - ) - } - placeholder="X-Organization-Id" - className="h-8 text-xs font-mono" - /> - - - - Value - - - setRemoteHeaders((headers) => - headers.map((current, headerIndex) => - headerIndex === index - ? { - ...current, - value: (event.target as HTMLInputElement).value, - } - : current, - ), - ) - } - placeholder="workspace-id" - className="h-8 text-xs font-mono" - /> - - - - ))} - - setRemoteHeaders((headers) => [...headers, { name: "", value: "" }]) - } - /> - > + : { kind: "idle" } + } + > + {!tokens && state.step === "probed" && probe.supportsDynamicRegistration && ( + + Sign in + )} - - + {!tokens && state.step === "oauth-waiting" && ( + + Cancel + + )} + + )} )} @@ -944,29 +771,3 @@ export default function AddMcpSource(props: { ); } - -function AddPlainHeaderRow({ - onClick, - leading, -}: { - readonly onClick: () => void; - readonly leading?: ReactNode; -}) { - return ( - // oxlint-disable-next-line react/forbid-elements - { - event.stopPropagation(); - onClick(); - }} - aria-label="Add header" - className="flex w-full items-center justify-between gap-4 px-4 py-3 text-sm text-muted-foreground outline-none transition-[background-color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:bg-accent/40 focus-visible:bg-accent/40" - > - {leading} - - - - - ); -} diff --git a/packages/plugins/mcp/src/react/EditMcpSource.tsx b/packages/plugins/mcp/src/react/EditMcpSource.tsx index 1bb1370ed..03c4254ac 100644 --- a/packages/plugins/mcp/src/react/EditMcpSource.tsx +++ b/packages/plugins/mcp/src/react/EditMcpSource.tsx @@ -14,7 +14,7 @@ import { slugifyNamespace, useSourceIdentity } from "@executor-js/react/plugins/ import { useCredentialTargetScope } from "@executor-js/react/plugins/credential-target-scope"; import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets"; import { - HttpCredentialsEditor, + HttpCredentials, serializeHttpCredentials, serializeScopedHttpCredentials, type HttpCredentialsState, @@ -174,7 +174,7 @@ function RemoteEditForm(props: { namespaceReadOnly /> - + > + + + {oauth2 && ( { const prefix = prefixForHeader(preset, headerName); return { name: headerName, secretId: null, prefix, - presetKey: matchPresetKey(headerName, prefix), + presetKey: matchHttpCredentialPreset(headerName, prefix), fromPreset: true, }; }); @@ -212,7 +211,7 @@ export default function AddOpenApiSource(props: { // Auth const [strategy, setStrategy] = useState({ kind: "none" }); - const [customHeaders, setCustomHeaders] = useState([]); + const [customHeaders, setCustomHeaders] = useState([]); const [specFetchCredentials, setSpecFetchCredentials] = useState(() => emptyHttpCredentials(), ); @@ -332,59 +331,23 @@ export default function AddOpenApiSource(props: { const resolvedBaseUrl = baseUrl.trim(); - const configuredHeaders: Record = {}; - const headerBindings: Array<{ - slot: string; - secretId: string; - scope: ScopeId; - secretScope: ScopeId; - }> = []; - const configuredQueryParams: Record = {}; - const queryParamBindings: Array<{ - slot: string; - secretId: string; - scope: ScopeId; - secretScope: ScopeId; - }> = []; - for (const ch of customHeaders) { - if (!ch.name.trim()) continue; - const slot = headerBindingSlot(ch.name.trim()); - configuredHeaders[ch.name.trim()] = ConfiguredHeaderBinding.make({ - kind: "binding", - slot, - prefix: ch.prefix, - }); - if (ch.secretId) { - headerBindings.push({ - slot, - secretId: ch.secretId, - scope: ch.targetScope ?? credentialTargetScope, - secretScope: ch.secretScope ?? ch.targetScope ?? credentialTargetScope, - }); - } - } - for (const param of runtimeCredentials.queryParams) { - const name = param.name.trim(); - if (!name) continue; - if (param.secretId) { - const slot = queryParamBindingSlot(name); - configuredQueryParams[name] = ConfiguredHeaderBinding.make({ - kind: "binding", - slot, - prefix: param.prefix, - }); - queryParamBindings.push({ - slot, - secretId: param.secretId, - scope: param.targetScope ?? credentialTargetScope, - secretScope: param.secretScope ?? param.targetScope ?? credentialTargetScope, - }); - continue; - } - if (param.literalValue?.trim()) { - configuredQueryParams[name] = param.literalValue.trim(); - } - } + const headerCredentialConfig = configuredCredentialMapFromRows( + customHeaders, + credentialTargetScope, + headerBindingSlot, + ); + const configuredHeaders = headerCredentialConfig.values as Record; + const headerBindings = headerCredentialConfig.bindings; + const queryParamCredentialConfig = configuredCredentialMapFromRows( + runtimeCredentials.queryParams, + credentialTargetScope, + queryParamBindingSlot, + ); + const configuredQueryParams = queryParamCredentialConfig.values as Record< + string, + ConfiguredHeaderValue + >; + const queryParamBindings = queryParamCredentialConfig.bindings; const oauth2Presets: readonly OAuth2Preset[] = preview?.oauth2Presets ?? []; const oauth2RedirectUrl = oauthCallbackUrl(OPENAPI_OAUTH_CALLBACK_PATH); @@ -438,7 +401,9 @@ export default function AddOpenApiSource(props: { const hasIncompleteHeaderCredentials = strategy.kind !== "none" && strategy.kind !== "oauth2" && - customHeaders.some((header) => header.name.trim() && !header.secretId); + customHeaders.some( + (header) => header.name.trim() && !header.secretId && !header.literalValue?.trim(), + ); const hasIncompleteQueryCredentials = runtimeCredentials.queryParams.some( (param) => param.name.trim() && !param.secretId && !param.literalValue?.trim(), ); @@ -527,7 +492,7 @@ export default function AddOpenApiSource(props: { ); }; - const handleHeadersChange = (next: HeaderState[]) => { + const handleHeadersChange = (next: HttpCredentialRow[]) => { setCustomHeaders(next); if (strategy.kind === "header" && next.every((h) => !h.fromPreset)) { setStrategy(next.length === 0 ? { kind: "none" } : { kind: "custom" }); @@ -903,17 +868,16 @@ export default function AddOpenApiSource(props: { - + > + + + > @@ -1038,22 +1002,21 @@ export default function AddOpenApiSource(props: { {/* Header-based auth input */} {strategy.kind !== "none" && strategy.kind !== "oauth2" && ( - - - + handleHeadersChange(credentials.headers)} + existingSecrets={secretList} + sourceName={identity.name} + targetScope={credentialTargetScope} + credentialScopeOptions={initialCredentialScopeOptions} + bindingScopeOptions={bindingScopeOptions} + restrictSecretsToTargetScope + > + + )} - + > + + {/* OAuth2 configuration */} {selectedOAuth2Preset && ( @@ -1179,65 +1142,58 @@ export default function AddOpenApiSource(props: { - {oauth2Auth ? ( - - - Connected · {oauth2SelectedScopes.size} scope - {oauth2SelectedScopes.size === 1 ? "" : "s"} granted - + { + setOAuthTokenTargetScope(targetScope); + setOauth2AuthState(null); + }} + label="OAuth sign-in" + scopeLabel="Token saved to" + scopeHelp="Choose who can use the signed-in OAuth token." + status={ + oauth2Auth + ? { + kind: "connected", + label: `Connected · ${oauth2SelectedScopes.size} scope${ + oauth2SelectedScopes.size === 1 ? "" : "s" + } granted`, + } + : oauth2Busy + ? { + kind: "busy", + label: + "Waiting for OAuth... complete the flow in the popup, or cancel to retry.", + } + : { kind: "idle" } + } + > + {oauth2Auth ? ( setOauth2AuthState(null)}> Disconnect - - ) : oauth2Busy ? ( - - - - Waiting for OAuth… complete the flow in the popup, or cancel to retry. - - - Cancel - - - Retry + ) : oauth2Busy ? ( + <> + + Cancel + + + Retry + + > + ) : ( + + Sign in - - ) : ( - - - - - OAuth sign-in - - Start the provider OAuth flow. - - - - Connect via OAuth - - - { - setOAuthTokenTargetScope(targetScope); - setOauth2AuthState(null); - }} - label="Token saved to" - help="Choose who can use the signed-in OAuth token." - /> - - - )} + )} + {oauth2Error && ( diff --git a/packages/react/src/pages/secrets.tsx b/packages/react/src/pages/secrets.tsx index a78338943..ffa254741 100644 --- a/packages/react/src/pages/secrets.tsx +++ b/packages/react/src/pages/secrets.tsx @@ -58,7 +58,7 @@ const isSecretInUseError = Schema.is(SecretInUseError); // // Form state, derived id, dup detection, and submit lifecycle live in // `` and are shared with the inline create flow in -// secret-header-auth.tsx. Dialog content remounts on each open via `key` so +// creatable-secret-picker.tsx. Dialog content remounts on each open via `key` so // state always starts fresh — no manual reset. // --------------------------------------------------------------------------- diff --git a/packages/react/src/plugins/creatable-secret-picker.tsx b/packages/react/src/plugins/creatable-secret-picker.tsx new file mode 100644 index 000000000..d289cf9dd --- /dev/null +++ b/packages/react/src/plugins/creatable-secret-picker.tsx @@ -0,0 +1,173 @@ +import { useState } from "react"; + +import { ScopeId } from "@executor-js/sdk"; +import { Button } from "../components/button"; +import { FieldGroup } from "../components/field"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogHeader, + DialogTitle, +} from "../components/dialog"; +import { SecretForm } from "./secret-form"; +import { SecretPicker, type SecretPickerSecret } from "./secret-picker"; +import { + CredentialTargetScopeSelector, + type CredentialTargetScopeOption, +} from "./credential-target-scope"; +import { secretsForCredentialTarget } from "./secret-credential-scope"; + +function CreateSecretContent(props: { + suggestedName: string; + existingSecretIds: readonly string[]; + onCreated: (secretId: string, scopeId: ScopeId) => void; + onCancel?: () => void; + fallbackId?: string; + targetScope: ScopeId; + credentialScopeOptions?: readonly CredentialTargetScopeOption[]; +}) { + const [scopeId, setScopeId] = useState(props.targetScope); + const activeScope = props.credentialScopeOptions?.find((option) => option.scopeId === scopeId); + + return ( + props.onCreated(secretId, scopeId)} + > + + {props.credentialScopeOptions && props.credentialScopeOptions.length > 1 && ( + + )} + + + + + + + + + {props.onCancel && ( + + Cancel + + )} + Create and use + + + + ); +} + +function CreateSecretDialog(props: { + readonly open: boolean; + readonly onOpenChange: (open: boolean) => void; + readonly suggestedName: string; + readonly existingSecretIds: readonly string[]; + readonly onCreated: (secretId: string, scopeId: ScopeId) => void; + readonly fallbackId?: string; + readonly targetScope: ScopeId; + readonly credentialScopeOptions?: readonly CredentialTargetScopeOption[]; +}) { + return ( + + + + New secret + + Create a reusable secret, then use it for this credential. + + + props.onOpenChange(false)} + targetScope={props.targetScope} + credentialScopeOptions={props.credentialScopeOptions} + /> + + + ); +} + +// --------------------------------------------------------------------------- +// CreatableSecretPicker — SecretPicker + inline "+ New secret" create flow +// --------------------------------------------------------------------------- + +export function CreatableSecretPicker(props: { + readonly value: string | null; + readonly valueScope?: ScopeId; + readonly onSelect: (secretId: string, scopeId?: ScopeId) => void; + readonly secrets: readonly SecretPickerSecret[]; + readonly placeholder?: string; + readonly targetScope: ScopeId; + readonly credentialScopeOptions?: readonly CredentialTargetScopeOption[]; + readonly onCreatedScope?: (scopeId: ScopeId) => void; + readonly suggestedId?: string; + /** + * Display name of the source the secret belongs to (e.g. "Stripe"). + * Combined with `secretLabel` to produce a suggested name/ID. + */ + readonly sourceName?: string; + /** Role of this secret (e.g. "Client ID", "API Token"). */ + readonly secretLabel: string; +}) { + const { + value, + valueScope, + onSelect, + secrets, + placeholder, + sourceName, + secretLabel, + targetScope, + credentialScopeOptions, + onCreatedScope, + suggestedId: suggestedIdProp, + } = props; + const [creating, setCreating] = useState(false); + + const suggestedName = [sourceName?.trim(), secretLabel].filter(Boolean).join(" "); + const scopedSecrets = secretsForCredentialTarget(secrets, targetScope); + + if (creating) { + return ( + secret.id)} + fallbackId={suggestedIdProp?.trim() || "secret"} + onCreated={(id, scopeId) => { + onCreatedScope?.(scopeId); + onSelect(id, scopeId); + setCreating(false); + }} + targetScope={targetScope} + credentialScopeOptions={credentialScopeOptions} + /> + ); + } + + return ( + onSelect(id, ScopeId.make(scopeId))} + secrets={secrets} + placeholder={placeholder} + onCreateNew={() => setCreating(true)} + /> + ); +} diff --git a/packages/react/src/plugins/credential-bindings.test.ts b/packages/react/src/plugins/credential-bindings.test.ts index a76f12b3c..137aa1d88 100644 --- a/packages/react/src/plugins/credential-bindings.test.ts +++ b/packages/react/src/plugins/credential-bindings.test.ts @@ -91,6 +91,10 @@ describe("credential binding editor helpers", () => { slot: "header:authorization", prefix: "Bearer ", }, + Mode: { + slot: "header:mode", + prefix: "mode=", + }, "X-Literal": "literal", }, [ @@ -102,6 +106,14 @@ describe("credential binding editor helpers", () => { secretId: SecretId.make("api-token"), }, }, + { + slotKey: "header:mode", + scopeId: ScopeId.make("user_1"), + value: { + kind: "text", + text: "fast", + }, + }, ], ), ).toEqual({ @@ -109,6 +121,7 @@ describe("credential binding editor helpers", () => { secretId: "api-token", prefix: "Bearer ", }, + Mode: "mode=fast", "X-Literal": "literal", }); }); diff --git a/packages/react/src/plugins/credential-bindings.tsx b/packages/react/src/plugins/credential-bindings.tsx index 3b1b10618..9352e3f66 100644 --- a/packages/react/src/plugins/credential-bindings.tsx +++ b/packages/react/src/plugins/credential-bindings.tsx @@ -1,7 +1,11 @@ import { ScopeId, type CredentialBindingValue, type SecretBackedValue } from "@executor-js/sdk"; -import type { HttpCredentialsState, QueryParamState } from "./http-credentials"; -import { headerValueToState, type HeaderState } from "./secret-header-auth"; +import { + httpCredentialRowFromValue, + type HttpCredentialRow, + type HttpCredentialsState, + type QueryParamState, +} from "./http-credentials"; type ConfiguredCredentialValueLike = | string @@ -59,15 +63,15 @@ const headerFromConfiguredCredential = ( name: string, value: ConfiguredCredentialValueLike, bindings: ReadonlyMap, -): HeaderState | null => { +): HttpCredentialRow | null => { if (typeof value === "string") { - return headerValueToState(name, value); + return httpCredentialRowFromValue(name, value); } const binding = bindings.get(value.slot); if (binding?.value.kind === "secret") { return { - ...headerValueToState(name, { + ...httpCredentialRowFromValue(name, { secretId: binding.value.secretId, prefix: value.prefix, }), @@ -77,7 +81,10 @@ const headerFromConfiguredCredential = ( } if (binding?.value.kind === "text") { - return headerValueToState(name, binding.value.text); + return { + ...httpCredentialRowFromValue(name, binding.value.text), + prefix: value.prefix, + }; } return null; @@ -104,7 +111,12 @@ const queryParamFromConfiguredCredential = ( } if (binding?.value.kind === "text") { - return { name, secretId: null, literalValue: binding.value.text }; + return { + name, + secretId: null, + literalValue: binding.value.text, + prefix: value.prefix, + }; } return null; @@ -130,7 +142,7 @@ export const secretBackedValuesFromConfiguredCredentialBindings = ( ...(value.prefix ? { prefix: value.prefix } : {}), }; } else if (binding?.value.kind === "text") { - out[name] = binding.value.text; + out[name] = value.prefix ? `${value.prefix}${binding.value.text}` : binding.value.text; } } diff --git a/packages/react/src/plugins/credential-slot-bindings.tsx b/packages/react/src/plugins/credential-slot-bindings.tsx index b7ecb59e9..77a82e4d7 100644 --- a/packages/react/src/plugins/credential-slot-bindings.tsx +++ b/packages/react/src/plugins/credential-slot-bindings.tsx @@ -9,7 +9,7 @@ import { isSecretCredentialBindingValue, type SourceCredentialBindingRef, } from "./credential-bindings"; -import { CreatableSecretPicker } from "./secret-header-auth"; +import { CreatableSecretPicker } from "./creatable-secret-picker"; import type { SecretPickerSecret } from "./secret-picker"; export type SecretCredentialSlot = { diff --git a/packages/react/src/plugins/headers-list.tsx b/packages/react/src/plugins/headers-list.tsx deleted file mode 100644 index 4ccf98df1..000000000 --- a/packages/react/src/plugins/headers-list.tsx +++ /dev/null @@ -1,225 +0,0 @@ -import { useState, type ReactNode } from "react"; -import { PlusIcon } from "lucide-react"; -import type { ScopeId } from "@executor-js/sdk"; - -import { Button } from "../components/button"; -import { - CardStack, - CardStackContent, - CardStackEmpty, - CardStackEntry, -} from "../components/card-stack"; -import { - defaultHeaderAuthPresets, - type HeaderAuthPreset, - type HeaderState, - SecretHeaderAuthRow, - type SecretCredentialPreviewComponent, - type SecretCredentialRowCopy, -} from "./secret-header-auth"; -import type { CredentialTargetScopeOption } from "./credential-target-scope"; -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; - readonly addLabel?: ReactNode; - readonly addAriaLabel?: string; - readonly rowCopy?: Partial; - readonly rowPreviewComponent?: SecretCredentialPreviewComponent; - /** - * Display name of the source that owns these headers (e.g. "Axiom"). Used - * to derive unique default secret labels/IDs like `axiom-authorization`. - */ - readonly sourceName?: string; - /** Inline-created secrets are written to this explicit scope. */ - readonly targetScope: ScopeId; - /** Scope choices shown only inside the inline "+ New secret" form. */ - readonly credentialScopeOptions?: readonly CredentialTargetScopeOption[]; - /** Scope choices for where this source credential is used. */ - readonly bindingScopeOptions?: readonly CredentialTargetScopeOption[]; - readonly restrictSecretsToTargetScope?: boolean; -} - -export function HeadersList({ - headers, - onHeadersChange, - existingSecrets = [], - presets = defaultHeaderAuthPresets, - singleHeader = false, - emptyLabel = "No headers", - addLabel, - addAriaLabel = "Add header", - rowCopy, - rowPreviewComponent, - sourceName, - targetScope, - credentialScopeOptions, - bindingScopeOptions, - restrictSecretsToTargetScope, -}: HeadersListProps) { - const [picking, setPicking] = useState(false); - const canAddMore = !singleHeader || headers.length === 0; - const addFirstPreset = () => { - const preset = presets[0]; - if (presets.length === 1 && preset) { - addHeaderFromPreset(preset); - return; - } - setPicking(true); - }; - - const addHeaderFromPreset = (preset: HeaderAuthPreset) => { - onHeadersChange([ - ...headers, - { - name: preset.name, - prefix: preset.prefix, - presetKey: preset.key, - secretId: null, - targetScope, - }, - ]); - setPicking(false); - }; - - const updateHeader = ( - index: number, - update: Partial<{ - name: string; - secretId: string | null; - prefix?: string; - presetKey?: string; - targetScope?: ScopeId; - secretScope?: ScopeId; - }>, - ) => { - 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={addFirstPreset} - ariaLabel={addAriaLabel} - /> - ) : ( - - {emptyLabel} - - ) - ) : ( - <> - {headers.map((header, index) => ( - updateHeader(index, update)} - onSelectSecret={(secretId, scopeId) => - updateHeader(index, { - secretId, - ...(scopeId ? { secretScope: scopeId } : {}), - }) - } - onRemove={singleHeader ? undefined : () => removeHeader(index)} - existingSecrets={existingSecrets} - sourceName={sourceName} - targetScope={header.targetScope ?? targetScope} - credentialScopeOptions={credentialScopeOptions} - bindingScopeOptions={bindingScopeOptions} - restrictSecretsToTargetScope={restrictSecretsToTargetScope} - copy={rowCopy} - previewComponent={rowPreviewComponent} - /> - ))} - {canAddMore && ( - - )} - > - )} - - - ); -} - -interface AddHeaderRowProps { - readonly onClick: () => void; - readonly leading?: ReactNode; - readonly ariaLabel: string; -} - -function AddHeaderRow({ onClick, leading, ariaLabel }: AddHeaderRowProps) { - return ( - // oxlint-disable-next-line react/forbid-elements - { - event.stopPropagation(); - onClick(); - }} - aria-label={ariaLabel} - className="flex w-full items-center justify-between gap-4 px-4 py-3 text-sm text-muted-foreground outline-none transition-[background-color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:bg-accent/40 focus-visible:bg-accent/40" - > - {leading} - - - ); -} - -interface HeaderPresetPickerProps { - readonly presets: readonly HeaderAuthPreset[]; - readonly onPick: (preset: HeaderAuthPreset) => void; - readonly onCancel: () => void; -} - -function HeaderPresetPicker({ presets, onPick, onCancel }: HeaderPresetPickerProps) { - return ( - - {presets.map((preset) => ( - onPick(preset)} - > - {preset.label} - - ))} - - Cancel - - - ); -} diff --git a/packages/react/src/plugins/http-credentials.test.ts b/packages/react/src/plugins/http-credentials.test.ts new file mode 100644 index 000000000..aa227bedc --- /dev/null +++ b/packages/react/src/plugins/http-credentials.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, it } from "@effect/vitest"; +import { ScopeId } from "@executor-js/sdk"; + +import { + configuredCredentialMapFromRows, + httpCredentialsValid, + serializeHttpCredentials, + serializeScopedHttpCredentials, + type HttpCredentialsState, +} from "./http-credentials"; + +describe("http credential editor helpers", () => { + it("serializes text and secret values for request previews", () => { + const credentials: HttpCredentialsState = { + headers: [ + { name: "Authorization", secretId: "api-token", prefix: "Bearer " }, + { name: "X-Static", secretId: null, literalValue: "static-value" }, + ], + queryParams: [ + { name: "api-version", secretId: null, literalValue: "2024-01-01" }, + { name: "token", secretId: "query-token" }, + ], + }; + + expect(httpCredentialsValid(credentials)).toBe(true); + expect(serializeHttpCredentials(credentials)).toEqual({ + headers: { + Authorization: { secretId: "api-token", prefix: "Bearer " }, + "X-Static": "static-value", + }, + queryParams: { + "api-version": "2024-01-01", + token: { secretId: "query-token" }, + }, + }); + }); + + it("serializes scoped secret values without forcing text values into bindings", () => { + const targetScope = ScopeId.make("org"); + const secretScope = ScopeId.make("user"); + + expect( + serializeScopedHttpCredentials( + { + headers: [ + { + name: "Authorization", + secretId: "api-token", + prefix: "Bearer ", + targetScope, + secretScope, + }, + { name: "X-Static", secretId: null, literalValue: "static-value" }, + ], + queryParams: [{ name: "api-version", secretId: null, literalValue: "2024-01-01" }], + }, + targetScope, + ), + ).toEqual({ + headers: { + Authorization: { + secretId: "api-token", + prefix: "Bearer ", + targetScope, + secretScopeId: secretScope, + }, + "X-Static": "static-value", + }, + queryParams: { + "api-version": "2024-01-01", + }, + }); + }); + + it("builds configured credential maps with bindings only for secret rows", () => { + const targetScope = ScopeId.make("org"); + const secretScope = ScopeId.make("user"); + + expect( + configuredCredentialMapFromRows( + [ + { + name: "Authorization", + secretId: "api-token", + prefix: "Bearer ", + targetScope, + secretScope, + }, + { name: "X-Static", secretId: null, literalValue: "static-value" }, + { name: "X-Deferred", secretId: null }, + ], + targetScope, + (name) => `header:${name.toLowerCase()}`, + ), + ).toEqual({ + values: { + Authorization: { + kind: "binding", + slot: "header:authorization", + prefix: "Bearer ", + }, + "X-Static": "static-value", + "X-Deferred": { + kind: "binding", + slot: "header:x-deferred", + }, + }, + bindings: [ + { + slot: "header:authorization", + secretId: "api-token", + scope: targetScope, + secretScope, + }, + ], + }); + }); +}); diff --git a/packages/react/src/plugins/http-credentials.tsx b/packages/react/src/plugins/http-credentials.tsx index 693f7f30a..f05f43daf 100644 --- a/packages/react/src/plugins/http-credentials.tsx +++ b/packages/react/src/plugins/http-credentials.tsx @@ -1,35 +1,140 @@ -import type { ScopeId, ScopedSecretCredentialInput, SecretBackedValue } from "@executor-js/sdk"; +import { createContext, useContext, useId, useState, type ReactNode } from "react"; +import { PlusIcon } from "lucide-react"; +import { + ScopeId, + type ScopedSecretCredentialInput, + type SecretBackedValue, +} from "@executor-js/sdk"; -import { FieldLabel } from "../components/field"; -import { HeadersList } from "./headers-list"; +import { Button } from "../components/button"; +import { CardStack, CardStackContent, CardStackEntry } from "../components/card-stack"; +import { Field, FieldGroup, FieldLabel } from "../components/field"; +import { HelpTooltip } from "../components/help-tooltip"; +import { Input } from "../components/input"; import { - headerValueToState, - headersFromState, - QueryParamCredentialValuePreview, - type HeaderAuthPreset, - type HeaderState, -} from "./secret-header-auth"; + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../components/select"; import type { CredentialTargetScopeOption } from "./credential-target-scope"; +import { secretsForCredentialTarget } from "./secret-credential-scope"; +import { CreatableSecretPicker } from "./creatable-secret-picker"; import type { SecretPickerSecret } from "./secret-picker"; export type { SecretBackedValue }; -export type QueryParamState = { +export type HttpCredentialValueKind = "secret" | "text"; + +export interface HttpCredentialPreset { + readonly key: string; + readonly label: string; + readonly name: string; + readonly prefix?: string; + readonly valueKind?: HttpCredentialValueKind; +} + +export const defaultHeaderCredentialPresets: readonly HttpCredentialPreset[] = [ + { + 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: "" }, +]; + +const queryParamCredentialPresets: readonly HttpCredentialPreset[] = [ + { key: "custom", label: "Query parameter", name: "", valueKind: "text" }, +]; + +export type HttpCredentialRow = { name: string; secretId: string | null; prefix?: string; literalValue?: string; + presetKey?: string; + fromPreset?: boolean; + /** Scope where this source credential value is used. */ targetScope?: ScopeId; + /** Scope that owns the selected reusable secret. */ secretScope?: ScopeId; }; -const queryParamPresets: readonly HeaderAuthPreset[] = [ - { key: "custom", label: "Query parameter", name: "" }, -]; +export type QueryParamState = HttpCredentialRow; export type HttpCredentialsState = { - headers: HeaderState[]; - queryParams: QueryParamState[]; + headers: HttpCredentialRow[]; + queryParams: HttpCredentialRow[]; +}; + +export type ConfiguredHttpCredentialBinding = { + readonly kind: "binding"; + readonly slot: string; + readonly prefix?: string; +}; + +export type ConfiguredHttpCredentialValue = string | ConfiguredHttpCredentialBinding; + +export type HttpCredentialBindingDraft = { + readonly slot: string; + readonly secretId: string; + readonly scope: ScopeId; + readonly secretScope: ScopeId; +}; + +type HttpCredentialSectionKind = "headers" | "queryParams"; + +type HttpCredentialRowCopy = { + readonly rowLabel: string; + readonly nameLabel: string; + readonly namePlaceholder: string; + readonly prefixLabel: string; + readonly prefixPlaceholder: string; + readonly secretLabel: string; + readonly secretHelp: string; + readonly textLabel: string; + readonly textHelp: string; + readonly textPlaceholder: string; + readonly valueSourceLabel: string; + readonly valueSourceHelp: string; + readonly usedByLabel: string; + readonly usedByHelp: string; +}; + +const headerRowCopy: HttpCredentialRowCopy = { + rowLabel: "Header", + nameLabel: "Name", + namePlaceholder: "Authorization", + prefixLabel: "Prefix", + prefixPlaceholder: "Bearer ", + secretLabel: "Secret", + secretHelp: "Select or create a reusable secret.", + textLabel: "Value", + textHelp: "Use a plain text value instead of a reusable secret.", + textPlaceholder: "value", + valueSourceLabel: "Value from", + valueSourceHelp: "Choose whether this value comes from a reusable secret or is saved directly.", + usedByLabel: "Used by", + usedByHelp: "Choose who uses this credential value.", +}; + +const queryParamRowCopy: HttpCredentialRowCopy = { + ...headerRowCopy, + rowLabel: "Query parameter", + namePlaceholder: "api-version", + prefixPlaceholder: "", }; export const emptyHttpCredentials = (): HttpCredentialsState => ({ @@ -37,100 +142,137 @@ export const emptyHttpCredentials = (): HttpCredentialsState => ({ queryParams: [], }); +export const matchHttpCredentialPreset = ( + name: string, + prefix?: string, + presets: readonly HttpCredentialPreset[] = defaultHeaderCredentialPresets, +): string => { + const preset = + presets.find((p) => p.name === name && p.prefix === prefix) ?? + presets.find((p) => p.name === name && p.prefix === undefined); + return preset?.key ?? "custom"; +}; + +export const httpCredentialRowFromValue = ( + name: string, + value: SecretBackedValue, + presets: readonly HttpCredentialPreset[] = defaultHeaderCredentialPresets, +): HttpCredentialRow => { + if (typeof value === "string") { + return { + name, + secretId: null, + literalValue: value, + presetKey: matchHttpCredentialPreset(name, undefined, presets), + }; + } + return { + name, + secretId: value.secretId, + prefix: value.prefix, + presetKey: matchHttpCredentialPreset(name, value.prefix, presets), + }; +}; + export const httpCredentialsFromValues = (input: { readonly headers?: Record | null; readonly queryParams?: Record | null; }): HttpCredentialsState => ({ headers: Object.entries(input.headers ?? {}).map(([name, value]) => - headerValueToState(name, value), + httpCredentialRowFromValue(name, value), + ), + queryParams: Object.entries(input.queryParams ?? {}).map(([name, value]) => + httpCredentialRowFromValue(name, value, queryParamCredentialPresets), ), - queryParams: Object.entries(input.queryParams ?? {}).map(([name, value]) => { - if (typeof value === "string") { - return { name, secretId: null, literalValue: value }; - } - return { name, secretId: value.secretId, prefix: value.prefix }; - }), }); -export const serializeHeaderCredentials = ( - headers: readonly HeaderState[], -): Record => headersFromState(headers); +const rowValueKind = (row: HttpCredentialRow): HttpCredentialValueKind => + row.literalValue === undefined ? "secret" : "text"; -export const serializeQueryCredentials = ( - queryParams: readonly QueryParamState[], +const literalValueWithPrefix = (row: HttpCredentialRow): string | null => { + const value = row.literalValue?.trim(); + if (!value) return null; + return row.prefix ? `${row.prefix}${value}` : value; +}; + +export const serializeCredentialRows = ( + rows: readonly HttpCredentialRow[], ): Record => { const result: Record = {}; - for (const param of queryParams) { - const name = param.name.trim(); + for (const row of rows) { + const name = row.name.trim(); if (!name) continue; - if (param.secretId) { + if (row.secretId) { result[name] = { - secretId: param.secretId, - ...(param.prefix ? { prefix: param.prefix } : {}), + secretId: row.secretId, + ...(row.prefix ? { prefix: row.prefix } : {}), }; continue; } - if (param.literalValue?.trim()) { - result[name] = param.literalValue.trim(); + const literalValue = literalValueWithPrefix(row); + if (literalValue) { + result[name] = literalValue; } } return result; }; +export const serializeHeaderCredentials = ( + headers: readonly HttpCredentialRow[], +): Record => serializeCredentialRows(headers); + +export const serializeQueryCredentials = ( + queryParams: readonly HttpCredentialRow[], +): Record => serializeCredentialRows(queryParams); + export const serializeHttpCredentials = ( credentials: HttpCredentialsState, ): { - readonly headers: Record; + readonly headers: Record; readonly queryParams: Record; } => ({ headers: serializeHeaderCredentials(credentials.headers), queryParams: serializeQueryCredentials(credentials.queryParams), }); -export const serializeScopedHeaderCredentials = ( - headers: readonly HeaderState[], - fallbackTargetScope: ScopeId, -): Record => { - const result: Record = {}; - for (const header of headers) { - const name = header.name.trim(); - if (!name || !header.secretId) continue; - const targetScope = header.targetScope ?? fallbackTargetScope; - result[name] = { - secretId: header.secretId, - targetScope, - ...(header.secretScope ? { secretScopeId: header.secretScope } : {}), - ...(header.prefix ? { prefix: header.prefix } : {}), - }; - } - return result; -}; - -export const serializeScopedQueryCredentials = ( - queryParams: readonly QueryParamState[], +export const serializeScopedCredentialRows = ( + rows: readonly HttpCredentialRow[], fallbackTargetScope: ScopeId, ): Record => { const result: Record = {}; - for (const param of queryParams) { - const name = param.name.trim(); + for (const row of rows) { + const name = row.name.trim(); if (!name) continue; - if (param.secretId) { - const targetScope = param.targetScope ?? fallbackTargetScope; + if (row.secretId) { + const targetScope = row.targetScope ?? fallbackTargetScope; result[name] = { - secretId: param.secretId, + secretId: row.secretId, targetScope, - ...(param.secretScope ? { secretScopeId: param.secretScope } : {}), - ...(param.prefix ? { prefix: param.prefix } : {}), + ...(row.secretScope ? { secretScopeId: row.secretScope } : {}), + ...(row.prefix ? { prefix: row.prefix } : {}), }; continue; } - if (param.literalValue?.trim()) { - result[name] = param.literalValue.trim(); + const literalValue = literalValueWithPrefix(row); + if (literalValue) { + result[name] = literalValue; } } return result; }; +export const serializeScopedHeaderCredentials = ( + headers: readonly HttpCredentialRow[], + fallbackTargetScope: ScopeId, +): Record => + serializeScopedCredentialRows(headers, fallbackTargetScope); + +export const serializeScopedQueryCredentials = ( + queryParams: readonly HttpCredentialRow[], + fallbackTargetScope: ScopeId, +): Record => + serializeScopedCredentialRows(queryParams, fallbackTargetScope); + export const serializeScopedHttpCredentials = ( credentials: HttpCredentialsState, fallbackTargetScope: ScopeId, @@ -139,14 +281,57 @@ export const serializeScopedHttpCredentials = ( queryParams: serializeScopedQueryCredentials(credentials.queryParams, fallbackTargetScope), }); +const rowValid = (row: HttpCredentialRow): boolean => { + if (!row.name.trim()) return false; + return Boolean(row.secretId || row.literalValue?.trim()); +}; + export const httpCredentialsValid = (credentials: HttpCredentialsState): boolean => - credentials.headers.every((header) => header.name.trim() && header.secretId) && - credentials.queryParams.every((param) => { - if (!param.name.trim()) return false; - return Boolean(param.secretId || param.literalValue?.trim()); - }); + credentials.headers.every(rowValid) && credentials.queryParams.every(rowValid); + +export const configuredCredentialMapFromRows = ( + rows: readonly HttpCredentialRow[], + fallbackTargetScope: ScopeId, + slotForName: (name: string) => string, +): { + readonly values: Record; + readonly bindings: readonly HttpCredentialBindingDraft[]; +} => { + const values: Record = {}; + const bindings: HttpCredentialBindingDraft[] = []; + + for (const row of rows) { + const name = row.name.trim(); + if (!name) continue; + + const literalValue = literalValueWithPrefix(row); + if (!row.secretId && literalValue) { + values[name] = literalValue; + continue; + } + + const slot = slotForName(name); + values[name] = { + kind: "binding", + slot, + ...(row.prefix ? { prefix: row.prefix } : {}), + }; + + if (row.secretId) { + const scope = row.targetScope ?? fallbackTargetScope; + bindings.push({ + slot, + secretId: row.secretId, + scope, + secretScope: row.secretScope ?? scope, + }); + } + } -export function HttpCredentialsEditor(props: { + return { values, bindings }; +}; + +type HttpCredentialsRootProps = { readonly credentials: HttpCredentialsState; readonly onChange: (credentials: HttpCredentialsState) => void; readonly existingSecrets: readonly SecretPickerSecret[]; @@ -155,60 +340,434 @@ export function HttpCredentialsEditor(props: { readonly credentialScopeOptions?: readonly CredentialTargetScopeOption[]; readonly bindingScopeOptions?: readonly CredentialTargetScopeOption[]; readonly restrictSecretsToTargetScope?: boolean; - readonly sections?: { - readonly headers?: boolean; - readonly queryParams?: boolean; + readonly children: ReactNode; +}; + +type HttpCredentialsContextValue = Omit; + +const HttpCredentialsContext = createContext(null); + +const useHttpCredentialsContext = (): HttpCredentialsContextValue => { + const context = useContext(HttpCredentialsContext); + if (context) return context; + // oxlint-disable-next-line executor/no-try-catch-or-throw, executor/no-error-constructor -- boundary: React composition invariant + throw new Error( + "HttpCredentials compound components must be rendered inside .", + ); +}; + +export function HttpCredentialsRoot(props: HttpCredentialsRootProps) { + const { children, ...context } = props; + return ( + + {children} + + ); +} + +export function HttpCredentialsHeaders(props: { readonly label?: string }) { + const context = useHttpCredentialsContext(); + return ( + context.onChange({ ...context.credentials, headers })} + presets={defaultHeaderCredentialPresets} + emptyLabel="No headers" + addLabel="Add header" + addAriaLabel="Add header" + rowCopy={headerRowCopy} + defaultValueKind="secret" + /> + ); +} + +export function HttpCredentialsQueryParams(props: { readonly label?: string }) { + const context = useHttpCredentialsContext(); + return ( + context.onChange({ ...context.credentials, queryParams })} + presets={queryParamCredentialPresets} + emptyLabel="No query parameters" + addLabel="Add query parameter" + addAriaLabel="Add query parameter" + rowCopy={queryParamRowCopy} + defaultValueKind="text" + /> + ); +} + +export const HttpCredentials = { + Root: HttpCredentialsRoot, + Headers: HttpCredentialsHeaders, + QueryParams: HttpCredentialsQueryParams, +} as const; + +function HttpCredentialSection(props: { + readonly kind: HttpCredentialSectionKind; + readonly label: string; + readonly rows: readonly HttpCredentialRow[]; + readonly onRowsChange: (rows: HttpCredentialRow[]) => void; + readonly presets: readonly HttpCredentialPreset[]; + readonly emptyLabel: ReactNode; + readonly addLabel: ReactNode; + readonly addAriaLabel: string; + readonly rowCopy: HttpCredentialRowCopy; + readonly defaultValueKind: HttpCredentialValueKind; +}) { + const context = useHttpCredentialsContext(); + const [picking, setPicking] = useState(false); + const addCredentialFromPreset = (preset: HttpCredentialPreset) => { + props.onRowsChange([ + ...props.rows, + { + name: preset.name, + prefix: preset.prefix, + presetKey: preset.key, + secretId: null, + literalValue: (preset.valueKind ?? props.defaultValueKind) === "text" ? "" : undefined, + targetScope: context.targetScope, + }, + ]); + setPicking(false); }; - readonly labels?: { - readonly headers?: string; - readonly queryParams?: string; + const addFirstPreset = () => { + const preset = props.presets[0]; + if (props.presets.length === 1 && preset) { + addCredentialFromPreset(preset); + return; + } + setPicking(true); + }; + const updateRow = (index: number, update: Partial) => { + props.onRowsChange( + props.rows.map((entry, i) => (i === index ? { ...entry, ...update } : entry)), + ); }; + const removeRow = (index: number) => { + props.onRowsChange(props.rows.filter((_, i) => i !== index)); + }; + + return ( + + {props.label} + + + {picking ? ( + setPicking(false)} + /> + ) : props.rows.length === 0 ? ( + {props.emptyLabel}} + onClick={addFirstPreset} + ariaLabel={props.addAriaLabel} + /> + ) : ( + <> + {props.rows.map((row, index) => ( + updateRow(index, update)} + onRemove={() => removeRow(index)} + /> + ))} + + > + )} + + + + ); +} + +function AddCredentialRow(props: { + readonly onClick: () => void; + readonly leading?: ReactNode; + readonly ariaLabel: string; }) { - const showHeaders = props.sections?.headers ?? true; - const showQueryParams = props.sections?.queryParams ?? true; + return ( + // oxlint-disable-next-line react/forbid-elements + { + event.stopPropagation(); + props.onClick(); + }} + aria-label={props.ariaLabel} + className="flex w-full items-center justify-between gap-4 px-4 py-3 text-sm text-muted-foreground outline-none transition-[background-color] duration-150 ease-[cubic-bezier(0.23,1,0.32,1)] hover:bg-accent/40 focus-visible:bg-accent/40" + > + {props.leading} + + + ); +} + +function HttpCredentialPresetPicker(props: { + readonly presets: readonly HttpCredentialPreset[]; + readonly onPick: (preset: HttpCredentialPreset) => void; + readonly onCancel: () => void; +}) { + return ( + + {props.presets.map((preset) => ( + props.onPick(preset)} + > + {preset.label} + + ))} + + Cancel + + + ); +} + +function InfoLabel(props: { readonly children: string; readonly tooltip: string }) { + return ( + + {props.children} + {props.tooltip} + + ); +} + +function HttpCredentialRowEditor(props: { + readonly kind: HttpCredentialSectionKind; + readonly row: HttpCredentialRow; + readonly rowCopy: HttpCredentialRowCopy; + readonly onChange: (update: Partial) => void; + readonly onRemove: () => void; +}) { + const context = useHttpCredentialsContext(); + const nameInputId = useId(); + const prefixInputId = useId(); + const valueKind = rowValueKind(props.row); + const targetScope = props.row.targetScope ?? context.targetScope; + const scopedSecrets = secretsForCredentialTarget(context.existingSecrets, targetScope); + const selectableSecrets = context.restrictSecretsToTargetScope + ? scopedSecrets + : context.existingSecrets; + const isCustom = props.row.presetKey === "custom" || props.row.presetKey === undefined; + + const setValueKind = (kind: HttpCredentialValueKind) => { + if (kind === valueKind) return; + props.onChange({ + secretId: null, + secretScope: undefined, + literalValue: kind === "text" ? "" : undefined, + }); + }; + const setValueKindFromSelect = (kind: string) => { + if (kind === "secret" || kind === "text") { + setValueKind(kind); + } + }; return ( - - {showHeaders && ( - - {props.labels?.headers ?? "Headers"} - props.onChange({ ...props.credentials, headers })} - existingSecrets={props.existingSecrets} - sourceName={props.sourceName} - targetScope={props.targetScope} - credentialScopeOptions={props.credentialScopeOptions} - bindingScopeOptions={props.bindingScopeOptions} - restrictSecretsToTargetScope={props.restrictSecretsToTargetScope} + + + + {props.rowCopy.rowLabel} + + + Remove + + + + + + {props.rowCopy.nameLabel} + + props.onChange({ + name: (event.target as HTMLInputElement).value, + presetKey: isCustom ? "custom" : props.row.presetKey, + }) + } + placeholder={props.rowCopy.namePlaceholder} + className="font-mono" /> - - )} + + + + {props.rowCopy.prefixLabel}{" "} + (optional) + + + props.onChange({ + prefix: (event.target as HTMLInputElement).value || undefined, + presetKey: isCustom ? "custom" : props.row.presetKey, + }) + } + placeholder={props.rowCopy.prefixPlaceholder} + className="font-mono" + /> + + + + {props.rowCopy.valueSourceLabel} + + + + + + + Saved secret + Plain value + + + + - {showQueryParams && ( - - {props.labels?.queryParams ?? "Query parameters"} - props.onChange({ ...props.credentials, queryParams })} - existingSecrets={props.existingSecrets} - sourceName={props.sourceName} - targetScope={props.targetScope} - credentialScopeOptions={props.credentialScopeOptions} - bindingScopeOptions={props.bindingScopeOptions} - restrictSecretsToTargetScope={props.restrictSecretsToTargetScope} - presets={queryParamPresets} - emptyLabel="No query parameters" - addLabel="Add query parameter" - addAriaLabel="Add query parameter" - rowCopy={{ - rowLabel: "Query parameter", - namePlaceholder: "token", - }} - rowPreviewComponent={QueryParamCredentialValuePreview} + {valueKind === "secret" ? ( + 1 + ? "grid gap-2 md:grid-cols-2" + : undefined + } + > + + {props.rowCopy.secretLabel} + + props.onChange({ + secretId, + secretScope: scopeId, + literalValue: undefined, + }) + } + secrets={selectableSecrets} + sourceName={context.sourceName} + secretLabel={props.row.name.trim() || props.rowCopy.rowLabel} + targetScope={targetScope} + credentialScopeOptions={context.credentialScopeOptions} + onCreatedScope={(secretScope) => props.onChange({ secretScope })} + /> + + {context.bindingScopeOptions && context.bindingScopeOptions.length > 1 && ( + + {props.rowCopy.usedByLabel} + + props.onChange({ + secretId: null, + secretScope: undefined, + literalValue: undefined, + targetScope: ScopeId.make(nextScope), + }) + } + > + + + + + {context.bindingScopeOptions.map((option) => ( + + {option.label} + + ))} + + + + )} + + ) : ( + + {props.rowCopy.textLabel} + + props.onChange({ + secretId: null, + secretScope: undefined, + literalValue: (event.target as HTMLInputElement).value, + }) + } + placeholder={props.rowCopy.textPlaceholder} + className="font-mono" /> - + )} + + + + ); +} + +function HttpCredentialPreview(props: { + readonly kind: HttpCredentialSectionKind; + readonly row: HttpCredentialRow; +}) { + const name = props.row.name.trim(); + if (!name) return null; + const prefix = props.row.prefix; + const value = + rowValueKind(props.row) === "secret" + ? props.row.secretId + ? "•".repeat(12) + : null + : props.row.literalValue?.trim(); + if (!value) return null; + + if (props.kind === "queryParams") { + return ( + + ?{name}= + + {prefix && {prefix}} + {value} + + + ); + } + + return ( + + {name}: + + {prefix && {prefix}} + {value} + ); } diff --git a/packages/react/src/plugins/secret-header-auth.tsx b/packages/react/src/plugins/secret-header-auth.tsx deleted file mode 100644 index 2bf342b88..000000000 --- a/packages/react/src/plugins/secret-header-auth.tsx +++ /dev/null @@ -1,541 +0,0 @@ -import { useId, useState, type ReactNode } from "react"; - -import { ScopeId } from "@executor-js/sdk"; -import { Button } from "../components/button"; -import { Field, FieldGroup, FieldLabel } from "../components/field"; -import { HelpTooltip } from "../components/help-tooltip"; -import { Input } from "../components/input"; -import { - Dialog, - DialogContent, - DialogDescription, - DialogHeader, - DialogTitle, -} from "../components/dialog"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "../components/select"; -import { SecretForm } from "./secret-form"; -import { SecretPicker, type SecretPickerSecret } from "./secret-picker"; -import { - CredentialTargetScopeSelector, - type CredentialTargetScopeOption, -} from "./credential-target-scope"; -import { secretsForCredentialTarget } from "./secret-credential-scope"; - -export { secretsForCredentialTarget }; - -export interface HeaderAuthPreset { - readonly key: string; - readonly label: string; - readonly name: string; - readonly prefix?: string; -} - -export const defaultHeaderAuthPresets: readonly HeaderAuthPreset[] = [ - { - 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: "" }, -]; - -function CreateSecretContent(props: { - suggestedName: string; - existingSecretIds: readonly string[]; - onCreated: (secretId: string, scopeId: ScopeId) => void; - onCancel?: () => void; - fallbackId?: string; - targetScope: ScopeId; - credentialScopeOptions?: readonly CredentialTargetScopeOption[]; -}) { - const [scopeId, setScopeId] = useState(props.targetScope); - const activeScope = props.credentialScopeOptions?.find((option) => option.scopeId === scopeId); - - return ( - props.onCreated(secretId, scopeId)} - > - - {props.credentialScopeOptions && props.credentialScopeOptions.length > 1 && ( - - )} - - - - - - - - - {props.onCancel && ( - - Cancel - - )} - Create and use - - - - ); -} - -export function InlineCreateSecret(props: { - suggestedName: string; - existingSecretIds: readonly string[]; - onCreated: (secretId: string, scopeId: ScopeId) => void; - onCancel: () => void; - fallbackId?: string; - targetScope: ScopeId; - credentialScopeOptions?: readonly CredentialTargetScopeOption[]; -}) { - return ( - - - New secret - - - - ); -} - -function CreateSecretDialog(props: { - readonly open: boolean; - readonly onOpenChange: (open: boolean) => void; - readonly suggestedName: string; - readonly existingSecretIds: readonly string[]; - readonly onCreated: (secretId: string, scopeId: ScopeId) => void; - readonly fallbackId?: string; - readonly targetScope: ScopeId; - readonly credentialScopeOptions?: readonly CredentialTargetScopeOption[]; -}) { - return ( - - - - New secret - - Create a reusable secret, then use it for this credential. - - - props.onOpenChange(false)} - targetScope={props.targetScope} - credentialScopeOptions={props.credentialScopeOptions} - /> - - - ); -} - -export type SecretCredentialPreviewProps = { - readonly name: string; - readonly secretId: string; - readonly prefix?: string; -}; - -export type SecretCredentialPreviewComponent = (props: SecretCredentialPreviewProps) => ReactNode; - -export function HeaderCredentialValuePreview(props: SecretCredentialPreviewProps) { - const { name, prefix } = props; - const maskedValue = "•".repeat(12); - - return ( - - {name}: - - {prefix && {prefix}} - {maskedValue} - - - ); -} - -export function QueryParamCredentialValuePreview(props: SecretCredentialPreviewProps) { - const { name, prefix } = props; - const maskedValue = "•".repeat(12); - - return ( - - ?{name}= - - {prefix && {prefix}} - {maskedValue} - - - ); -} - -// --------------------------------------------------------------------------- -// Header state helpers — shared by edit forms -// --------------------------------------------------------------------------- - -export type HeaderState = { - name: string; - secretId: string | null; - prefix?: string; - presetKey?: string; - fromPreset?: boolean; - /** Scope where this source credential value is used. */ - targetScope?: ScopeId; - /** Scope that owns the selected reusable secret. */ - secretScope?: ScopeId; -}; - -export function matchPresetKey(name: string, prefix?: string): string { - const preset = - defaultHeaderAuthPresets.find((p) => p.name === name && p.prefix === prefix) ?? - defaultHeaderAuthPresets.find((p) => p.name === name && p.prefix === undefined); - return preset?.key ?? "custom"; -} - -function InfoLabel(props: { readonly children: string; readonly tooltip: string }) { - return ( - - {props.children} - {props.tooltip} - - ); -} - -export type SecretCredentialRowCopy = { - readonly rowLabel: string; - readonly nameLabel: string; - readonly namePlaceholder: string; - readonly prefixLabel: string; - readonly prefixPlaceholder: string; - readonly secretLabel: string; - readonly secretHelp: string; - readonly usedByLabel: string; - readonly usedByHelp: string; -}; - -const defaultSecretCredentialRowCopy: SecretCredentialRowCopy = { - rowLabel: "Header", - nameLabel: "Name", - namePlaceholder: "Authorization", - prefixLabel: "Prefix", - prefixPlaceholder: "Bearer ", - secretLabel: "Secret", - secretHelp: "Select or create a reusable secret.", - usedByLabel: "Used by", - usedByHelp: "Choose who uses this credential value.", -}; - -export function headerValueToState( - name: string, - value: { secretId: string; prefix?: string } | string, -): HeaderState { - if (typeof value === "string") { - return { name, secretId: null, presetKey: matchPresetKey(name, undefined) }; - } - return { - name, - secretId: value.secretId, - prefix: value.prefix, - presetKey: matchPresetKey(name, value.prefix), - }; -} - -export function headersFromState( - entries: readonly HeaderState[], -): Record { - const result: Record = {}; - for (const entry of entries) { - const name = entry.name.trim(); - if (!name || !entry.secretId) continue; - result[name] = { - secretId: entry.secretId, - ...(entry.prefix ? { prefix: entry.prefix } : {}), - }; - } - return result; -} - -// --------------------------------------------------------------------------- -// Secret header auth row -// --------------------------------------------------------------------------- - -export function SecretHeaderAuthRow(props: { - name: string; - prefix?: string; - presetKey?: string; - secretId: string | null; - secretScope?: ScopeId; - onChange: (update: { - name: string; - secretId?: string | null; - prefix?: string; - presetKey?: string; - targetScope?: ScopeId; - secretScope?: ScopeId; - }) => void; - onSelectSecret: (secretId: string, scopeId?: ScopeId) => void; - existingSecrets: readonly SecretPickerSecret[]; - onRemove?: () => void; - removeLabel?: string; - copy?: Partial; - previewComponent?: SecretCredentialPreviewComponent; - /** - * Display name of the source this header belongs to (e.g. "Axiom"). Used - * to prefix the suggested secret label and ID so tokens from different - * sources don't collide on ids like `authorization`. - */ - sourceName?: string; - targetScope: ScopeId; - credentialScopeOptions?: readonly CredentialTargetScopeOption[]; - bindingScopeOptions?: readonly CredentialTargetScopeOption[]; - restrictSecretsToTargetScope?: boolean; -}) { - const [creating, setCreating] = useState(false); - const nameInputId = useId(); - const prefixInputId = useId(); - const { - name, - prefix, - presetKey, - secretId, - secretScope, - onChange, - onSelectSecret, - existingSecrets, - onRemove, - removeLabel = "Remove", - copy: copyOverride, - previewComponent: PreviewComponent = HeaderCredentialValuePreview, - sourceName, - targetScope, - credentialScopeOptions, - bindingScopeOptions, - restrictSecretsToTargetScope = false, - } = props; - - const isCustom = presetKey === "custom" || presetKey === undefined; - const copy = { ...defaultSecretCredentialRowCopy, ...copyOverride }; - const headerLabel = name.trim() || "Custom Header"; - const suggestedName = [sourceName?.trim(), headerLabel].filter(Boolean).join(" "); - const scopedSecrets = secretsForCredentialTarget(existingSecrets, targetScope); - const selectableSecrets = restrictSecretsToTargetScope ? scopedSecrets : existingSecrets; - - return ( - - secret.id)} - onCreated={(id, scopeId) => { - onSelectSecret(id, scopeId); - setCreating(false); - }} - targetScope={targetScope} - credentialScopeOptions={credentialScopeOptions} - /> - - - {copy.rowLabel} - - {onRemove && ( - - {removeLabel} - - )} - - - - - {copy.nameLabel} - - onChange({ - name: (e.target as HTMLInputElement).value, - prefix, - presetKey: isCustom ? "custom" : presetKey, - }) - } - placeholder={copy.namePlaceholder} - className="font-mono" - /> - - - - {copy.prefixLabel}{" "} - (optional) - - - onChange({ - name, - prefix: (e.target as HTMLInputElement).value || undefined, - presetKey: isCustom ? "custom" : presetKey, - }) - } - placeholder={copy.prefixPlaceholder} - className="font-mono" - /> - - - - 1 - ? "grid gap-2 md:grid-cols-2" - : undefined - } - > - - {copy.secretLabel} - onSelectSecret(id, ScopeId.make(scopeId))} - secrets={selectableSecrets} - onCreateNew={() => setCreating(true)} - /> - - {bindingScopeOptions && bindingScopeOptions.length > 1 && ( - - {copy.usedByLabel} - - onChange({ - name, - secretId: null, - secretScope: undefined, - prefix, - presetKey, - targetScope: ScopeId.make(nextScope), - }) - } - > - - - - - {bindingScopeOptions.map((option) => ( - - {option.label} - - ))} - - - - )} - - - {secretId && name.trim() && ( - - )} - - ); -} - -// --------------------------------------------------------------------------- -// CreatableSecretPicker — SecretPicker + inline "+ New secret" create flow -// --------------------------------------------------------------------------- - -export function CreatableSecretPicker(props: { - readonly value: string | null; - readonly onSelect: (secretId: string, scopeId?: ScopeId) => void; - readonly secrets: readonly SecretPickerSecret[]; - readonly placeholder?: string; - readonly targetScope: ScopeId; - readonly credentialScopeOptions?: readonly CredentialTargetScopeOption[]; - readonly onCreatedScope?: (scopeId: ScopeId) => void; - readonly suggestedId?: string; - /** - * Display name of the source the secret belongs to (e.g. "Stripe"). - * Combined with `secretLabel` to produce a suggested name/ID. - */ - readonly sourceName?: string; - /** Role of this secret (e.g. "Client ID", "API Token"). */ - readonly secretLabel: string; -}) { - const { - value, - onSelect, - secrets, - placeholder, - sourceName, - secretLabel, - targetScope, - credentialScopeOptions, - onCreatedScope, - suggestedId: suggestedIdProp, - } = props; - const [creating, setCreating] = useState(false); - - const suggestedName = [sourceName?.trim(), secretLabel].filter(Boolean).join(" "); - const scopedSecrets = secretsForCredentialTarget(secrets, targetScope); - - if (creating) { - return ( - secret.id)} - fallbackId={suggestedIdProp?.trim() || "secret"} - onCreated={(id, scopeId) => { - onCreatedScope?.(scopeId); - onSelect(id, scopeId); - setCreating(false); - }} - targetScope={targetScope} - credentialScopeOptions={credentialScopeOptions} - /> - ); - } - - return ( - onSelect(id, ScopeId.make(scopeId))} - secrets={secrets} - placeholder={placeholder} - onCreateNew={() => setCreating(true)} - /> - ); -} diff --git a/packages/react/src/plugins/secret-header-auth.test.ts b/packages/react/src/plugins/secret-helpers.test.ts similarity index 100% rename from packages/react/src/plugins/secret-header-auth.test.ts rename to packages/react/src/plugins/secret-helpers.test.ts diff --git a/packages/react/src/plugins/source-oauth-connection.tsx b/packages/react/src/plugins/source-oauth-connection.tsx index c39285423..d4cd42039 100644 --- a/packages/react/src/plugins/source-oauth-connection.tsx +++ b/packages/react/src/plugins/source-oauth-connection.tsx @@ -1,5 +1,8 @@ +import type { ReactNode } from "react"; +import { CheckIcon } from "lucide-react"; import type { ConnectionId, ScopeId, SecretBackedValue } from "@executor-js/sdk"; +import { Spinner } from "../components/spinner"; import { CredentialControlField, CredentialUsageRow, @@ -7,6 +10,73 @@ import { } from "./credential-target-scope"; import { SourceOAuthSignInButton } from "./oauth-sign-in"; +export type OAuthConnectionStatus = + | { readonly kind: "idle"; readonly label?: ReactNode } + | { readonly kind: "busy"; readonly label?: ReactNode } + | { readonly kind: "connected"; readonly label?: ReactNode } + | { readonly kind: "blocked"; readonly label: ReactNode }; + +const statusClassByKind: Record = { + busy: "border-blue-500/30 bg-blue-500/5 text-blue-600 dark:text-blue-400", + connected: "border-emerald-500/30 bg-emerald-500/5 text-emerald-600 dark:text-emerald-400", + blocked: "border-border bg-muted/30 text-muted-foreground", + idle: "border-border bg-muted/30 text-muted-foreground", +}; + +const defaultStatusLabelByKind: Record = { + busy: "Connecting...", + connected: "Connected", + blocked: null, + idle: "Not connected", +}; + +const statusClasses = (status: OAuthConnectionStatus): string => statusClassByKind[status.kind]; + +const statusLabel = (status: OAuthConnectionStatus): ReactNode => { + if (status.label !== undefined) return status.label; + return defaultStatusLabelByKind[status.kind]; +}; + +export function OAuthConnectionControl(props: { + readonly tokenScope: ScopeId; + readonly onTokenScopeChange: (scope: ScopeId) => void; + readonly credentialScopeOptions: readonly CredentialTargetScopeOption[]; + readonly status: OAuthConnectionStatus; + readonly children?: ReactNode; + readonly label?: string; + readonly help?: ReactNode; + readonly scopeLabel?: string; + readonly scopeHelp?: ReactNode; +}) { + return ( + + + + {props.status.kind === "busy" && } + {props.status.kind === "connected" && } + {statusLabel(props.status)} + {props.children ? ( + {props.children} + ) : null} + + + + ); +} + export function SourceOAuthConnectionControl(props: { readonly popupName: string; readonly pluginId: string; @@ -27,42 +97,28 @@ export function SourceOAuthConnectionControl(props: { readonly signingInLabel?: string; }) { return ( - - - - {props.isConnected ? ( - - Connected - - ) : ( - Not connected - )} - - - - - - + + ); }
- Plaintext headers sent with every request. Use request headers above for - secret-backed values. -
- New secret -