Refactor source credential edit surfaces
Unified · Split
| @@ -122,9 +122,6 @@ export interface SourcePlugin { · 121 unmodified lines | ||
| 122 | 122 | readonly variant?: "badge" | "panel"; |
| 123 | 123 | readonly onAction?: () => void; |
| 124 | 124 | }>; |
| 125 | − readonly signIn?: ComponentType<{ | |
| 126 | − readonly sourceId: string; | |
| 127 | − }>; | |
| 128 | 125 | readonly presets?: readonly SourcePreset[]; |
| 129 | 126 | /** Trigger early download of the plugin's lazy component chunks (add/edit/etc.). |
| 130 | 127 | * Call from the host on intent (hover/focus) so the chunks land before the |
| @@ -5,6 +5,7 @@ import { Badge } from "@executor-js/react/components/badge"; · 4 unmodified lines | ||
| 5 | 5 | import { Button } from "@executor-js/react/components/button"; |
| 6 | 6 | |
| 7 | 7 | import { googleDiscoverySourceAtom } from "./atoms"; |
| 8 | +import GoogleDiscoverySignInButton from "./GoogleDiscoverySignInButton"; | |
| 8 | 9 | |
| 9 | 10 | export default function EditGoogleDiscoverySource({ |
| 10 | 11 | sourceId, |
| @@ -63,9 +64,12 @@ export default function EditGoogleDiscoverySource({ · 52 unmodified lines | ||
| 63 | 64 | <p className="text-xs uppercase tracking-wider text-muted-foreground mb-1"> |
| 64 | 65 | Authentication |
| 65 | 66 | </p> |
| 66 | − <p className="text-sm font-medium text-foreground capitalize"> | |
| 67 | − {authKind === "oauth2" ? "OAuth 2.0" : authKind} | |
| 68 | − </p> | |
| 67 | + <div className="flex min-h-9 items-center justify-between gap-3"> | |
| 68 | + <p className="text-sm font-medium text-foreground capitalize"> | |
| 69 | + {authKind === "oauth2" ? "OAuth 2.0" : authKind} | |
| 70 | + </p> | |
| 71 | + {authKind === "oauth2" && <GoogleDiscoverySignInButton sourceId={sourceId} />} | |
| 72 | + </div> | |
| 69 | 73 | </div> |
| 70 | 74 | </div> |
| 71 | 75 | )} |
| @@ -5,7 +5,6 @@ import { googleDiscoveryPresets } from "../sdk/presets"; · 4 unmodified lines | ||
| 5 | 5 | const importAdd = () => import("./AddGoogleDiscoverySource"); |
| 6 | 6 | const importEdit = () => import("./EditGoogleDiscoverySource"); |
| 7 | 7 | const importSummary = () => import("./GoogleDiscoverySourceSummary"); |
| 8 | −const importSignIn = () => import("./GoogleDiscoverySignInButton"); | |
| 9 | 8 | |
| 10 | 9 | export const googleDiscoverySourcePlugin: SourcePlugin = { |
| 11 | 10 | key: "googleDiscovery", |
| @@ -13,12 +12,10 @@ export const googleDiscoverySourcePlugin: SourcePlugin = { · 1 unmodified line | ||
| 13 | 12 | add: lazy(importAdd), |
| 14 | 13 | edit: lazy(importEdit), |
| 15 | 14 | summary: lazy(importSummary), |
| 16 | − signIn: lazy(importSignIn), | |
| 17 | 15 | presets: googleDiscoveryPresets, |
| 18 | 16 | preload: () => { |
| 19 | 17 | void importAdd(); |
| 20 | 18 | void importEdit(); |
| 21 | 19 | void importSummary(); |
| 22 | − void importSignIn(); | |
| 23 | 20 | }, |
| 24 | 21 | }; |
| @@ -16,7 +16,6 @@ import { · 15 unmodified lines | ||
| 16 | 16 | import { |
| 17 | 17 | sourceDisplayNameFromUrl, |
| 18 | 18 | slugifyNamespace, |
| 19 | − SourceIdentityFieldRows, | |
| 20 | 19 | useSourceIdentity, |
| 21 | 20 | } from "@executor-js/react/plugins/source-identity"; |
| 22 | 21 | import { |
| @@ -33,16 +32,11 @@ import { · 10 unmodified lines | ||
| 33 | 32 | import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets"; |
| 34 | 33 | import { Button } from "@executor-js/react/components/button"; |
| 35 | 34 | import { FilterTabs } from "@executor-js/react/components/filter-tabs"; |
| 36 | −import { | |
| 37 | − CardStack, | |
| 38 | − CardStackContent, | |
| 39 | − CardStackEntryField, | |
| 40 | −} from "@executor-js/react/components/card-stack"; | |
| 41 | 35 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 42 | −import { Input } from "@executor-js/react/components/input"; | |
| 43 | 36 | import { Spinner } from "@executor-js/react/components/spinner"; |
| 44 | 37 | import { addGraphqlSourceOptimistic } from "./atoms"; |
| 45 | 38 | import { initialGraphqlCredentials } from "./defaults"; |
| 39 | +import { GraphqlSourceFields } from "./GraphqlSourceFields"; | |
| 46 | 40 | import type { GraphqlCredentialInput } from "../sdk/types"; |
| 47 | 41 | |
| 48 | 42 | const ErrorMessage = Schema.Struct({ message: Schema.String }); |
| @@ -181,22 +175,7 @@ export default function AddGraphqlSource(props: { · 132 unmodified lines | ||
| 181 | 175 | <div className="flex flex-1 flex-col gap-6"> |
| 182 | 176 | <h1 className="text-xl font-semibold text-foreground">Add GraphQL Source</h1> |
| 183 | 177 | |
| 184 | − <CardStack> | |
| 185 | − <CardStackContent className="border-t-0"> | |
| 186 | − <CardStackEntryField | |
| 187 | − label="Endpoint" | |
| 188 | − hint="The endpoint will be introspected to discover available queries and mutations." | |
| 189 | − > | |
| 190 | − <Input | |
| 191 | − value={endpoint} | |
| 192 | − onChange={(e) => setEndpoint((e.target as HTMLInputElement).value)} | |
| 193 | − placeholder="https://api.example.com/graphql" | |
| 194 | − className="font-mono text-sm" | |
| 195 | − /> | |
| 196 | − </CardStackEntryField> | |
| 197 | − <SourceIdentityFieldRows identity={identity} namePlaceholder="e.g. Shopify API" /> | |
| 198 | − </CardStackContent> | |
| 199 | − </CardStack> | |
| 178 | + <GraphqlSourceFields endpoint={endpoint} onEndpointChange={setEndpoint} identity={identity} /> | |
| 200 | 179 | |
| 201 | 180 | <HttpCredentialsEditor |
| 202 | 181 | credentials={credentials} |
| @@ -2,40 +2,39 @@ import { useState } from "react"; · 1 unmodified line | ||
| 2 | 2 | import { useAtomValue, useAtomSet } from "@effect/atom-react"; |
| 3 | 3 | import * as Exit from "effect/Exit"; |
| 4 | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 5 | −import { graphqlSourceAtom, graphqlSourceBindingsAtom, updateGraphqlSource } from "./atoms"; | |
| 6 | −import { useScope } from "@executor-js/react/api/scope-context"; | |
| 7 | −import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; | |
| 5 | +import { | |
| 6 | + graphqlSourceAtom, | |
| 7 | + graphqlSourceBindingsAtom, | |
| 8 | + setGraphqlSourceBinding, | |
| 9 | + updateGraphqlSource, | |
| 10 | +} from "./atoms"; | |
| 11 | +import { connectionsAtom } from "@executor-js/react/api/atoms"; | |
| 12 | +import { useScope, useScopeStack } from "@executor-js/react/api/scope-context"; | |
| 13 | +import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; | |
| 8 | 14 | import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets"; |
| 9 | 15 | import { |
| 10 | 16 | HttpCredentialsEditor, |
| 17 | + serializeHttpCredentials, | |
| 11 | 18 | serializeScopedHttpCredentials, |
| 12 | 19 | type HttpCredentialsState, |
| 13 | 20 | } from "@executor-js/react/plugins/http-credentials"; |
| 14 | 21 | import { |
| 22 | + effectiveCredentialBindingForScope, | |
| 15 | 23 | httpCredentialsFromConfiguredCredentialBindings, |
| 16 | 24 | initialCredentialTargetScope, |
| 17 | 25 | } from "@executor-js/react/plugins/credential-bindings"; |
| 18 | −import { | |
| 19 | − SourceIdentityFields, | |
| 20 | − useSourceIdentity, | |
| 21 | −} from "@executor-js/react/plugins/source-identity"; | |
| 22 | −import { | |
| 23 | − CredentialTargetScopeSelector, | |
| 24 | − useCredentialTargetScope, | |
| 25 | −} from "@executor-js/react/plugins/credential-target-scope"; | |
| 26 | +import { slugifyNamespace, useSourceIdentity } from "@executor-js/react/plugins/source-identity"; | |
| 27 | +import { useCredentialTargetScope } from "@executor-js/react/plugins/credential-target-scope"; | |
| 26 | 28 | import { Button } from "@executor-js/react/components/button"; |
| 27 | 29 | import { FilterTabs } from "@executor-js/react/components/filter-tabs"; |
| 28 | −import { | |
| 29 | − CardStack, | |
| 30 | − CardStackContent, | |
| 31 | − CardStackEntryField, | |
| 32 | −} from "@executor-js/react/components/card-stack"; | |
| 33 | −import { Input } from "@executor-js/react/components/input"; | |
| 30 | +import { SourceOAuthConnectionControl } from "@executor-js/react/plugins/source-oauth-connection"; | |
| 34 | 31 | import { Badge } from "@executor-js/react/components/badge"; |
| 35 | 32 | import { ScopeId } from "@executor-js/sdk/core"; |
| 33 | +import { GraphqlSourceFields } from "./GraphqlSourceFields"; | |
| 36 | 34 | import { |
| 37 | 35 | GRAPHQL_OAUTH_CONNECTION_SLOT, |
| 38 | 36 | type GraphqlCredentialInput, |
| 37 | + GraphqlSourceBindingInput, | |
| 39 | 38 | type GraphqlSourceBindingRef, |
| 40 | 39 | } from "../sdk/types"; |
| 41 | 40 | import type { StoredGraphqlSource } from "../sdk/store"; |
| @@ -54,14 +53,23 @@ function EditForm(props: { · 12 unmodified lines | ||
| 54 | 53 | onSave: () => void; |
| 55 | 54 | }) { |
| 56 | 55 | const displayScope = useScope(); |
| 56 | + const scopeStack = useScopeStack(); | |
| 57 | 57 | const sourceScope = ScopeId.make(props.initial.scope); |
| 58 | − const { credentialTargetScope, setCredentialTargetScope, credentialScopeOptions } = | |
| 59 | − useCredentialTargetScope({ | |
| 60 | − sourceScope, | |
| 61 | − initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings), | |
| 62 | − }); | |
| 58 | + const { credentialTargetScope, credentialScopeOptions } = useCredentialTargetScope({ | |
| 59 | + sourceScope, | |
| 60 | + initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings), | |
| 61 | + }); | |
| 62 | + const { | |
| 63 | + credentialTargetScope: oauthCredentialTargetScope, | |
| 64 | + setCredentialTargetScope: setOAuthCredentialTargetScope, | |
| 65 | + } = useCredentialTargetScope({ | |
| 66 | + sourceScope, | |
| 67 | + initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings), | |
| 68 | + }); | |
| 63 | 69 | const doUpdate = useAtomSet(updateGraphqlSource, { mode: "promiseExit" }); |
| 70 | + const setBinding = useAtomSet(setGraphqlSourceBinding, { mode: "promise" }); | |
| 64 | 71 | const secretList = useSecretPickerSecrets(); |
| 72 | + const connectionsResult = useAtomValue(connectionsAtom(displayScope)); | |
| 65 | 73 | |
| 66 | 74 | const identity = useSourceIdentity({ |
| 67 | 75 | fallbackName: props.initial.name, |
| @@ -84,6 +92,23 @@ function EditForm(props: { · 16 unmodified lines | ||
| 84 | 92 | const identityDirty = identity.name.trim() !== props.initial.name.trim(); |
| 85 | 93 | const metadataDirty = identityDirty || endpoint.trim() !== props.initial.endpoint.trim(); |
| 86 | 94 | const dirty = metadataDirty || credentialsDirty || authDirty; |
| 95 | + const oauth2 = props.initial.auth.kind === "oauth2" ? props.initial.auth : null; | |
| 96 | + const connections = AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : []; | |
| 97 | + const scopeRanks = new Map(scopeStack.map((scope, index) => [scope.id, index] as const)); | |
| 98 | + const connectionBinding = oauth2 | |
| 99 | + ? effectiveCredentialBindingForScope( | |
| 100 | + props.bindings, | |
| 101 | + oauth2.connectionSlot, | |
| 102 | + oauthCredentialTargetScope, | |
| 103 | + scopeRanks, | |
| 104 | + ) | |
| 105 | + : null; | |
| 106 | + const boundConnectionId = | |
| 107 | + connectionBinding?.value.kind === "connection" ? connectionBinding.value.connectionId : null; | |
| 108 | + const isConnected = | |
| 109 | + boundConnectionId !== null && | |
| 110 | + connections.some((connection) => connection.id === boundConnectionId); | |
| 111 | + const oauthRequestCredentials = serializeHttpCredentials(credentials); | |
| 87 | 112 | |
| 88 | 113 | const handleCredentialsChange = (next: HttpCredentialsState) => { |
| 89 | 114 | setCredentials(next); |
| @@ -164,31 +189,11 @@ function EditForm(props: { · 74 unmodified lines | ||
| 164 | 189 | </Badge> |
| 165 | 190 | </div> |
| 166 | 191 | |
| 167 | − <SourceIdentityFields identity={identity} namespaceReadOnly /> | |
| 168 | − | |
| 169 | − <CardStack> | |
| 170 | − <CardStackContent className="border-t-0"> | |
| 171 | − <CardStackEntryField label="Endpoint"> | |
| 172 | − <Input | |
| 173 | − value={endpoint} | |
| 174 | − onChange={(e) => { | |
| 175 | − setEndpoint((e.target as HTMLInputElement).value); | |
| 176 | − }} | |
| 177 | − placeholder="https://api.example.com/graphql" | |
| 178 | − className="font-mono text-sm" | |
| 179 | − /> | |
| 180 | − </CardStackEntryField> | |
| 181 | − </CardStackContent> | |
| 182 | − </CardStack> | |
| 183 | − | |
| 184 | − <CredentialTargetScopeSelector | |
| 185 | − value={credentialTargetScope} | |
| 186 | − options={credentialScopeOptions} | |
| 187 | − onChange={(targetScope) => { | |
| 188 | − setCredentialTargetScope(targetScope); | |
| 189 | − setCredentialsDirty(true); | |
| 190 | − }} | |
| 191 | − description="Choose where updated GraphQL credentials are saved." | |
| 192 | + <GraphqlSourceFields | |
| 193 | + endpoint={endpoint} | |
| 194 | + onEndpointChange={setEndpoint} | |
| 195 | + identity={identity} | |
| 196 | + namespaceReadOnly | |
| 192 | 197 | /> |
| 193 | 198 | |
| 194 | 199 | <HttpCredentialsEditor |
| @@ -224,6 +229,37 @@ function EditForm(props: { · 29 unmodified lines | ||
| 224 | 229 | )} |
| 225 | 230 | </section> |
| 226 | 231 | |
| 232 | + {oauth2 && ( | |
| 233 | + <SourceOAuthConnectionControl | |
| 234 | + popupName="graphql-oauth" | |
| 235 | + pluginId="graphql" | |
| 236 | + namespace={slugifyNamespace(props.initial.namespace) || "graphql"} | |
| 237 | + fallbackNamespace="graphql" | |
| 238 | + endpoint={endpoint.trim()} | |
| 239 | + tokenScope={oauthCredentialTargetScope} | |
| 240 | + onTokenScopeChange={setOAuthCredentialTargetScope} | |
| 241 | + credentialScopeOptions={credentialScopeOptions} | |
| 242 | + connectionId={boundConnectionId} | |
| 243 | + sourceLabel={`${identity.name.trim() || props.initial.namespace || "GraphQL"} OAuth`} | |
| 244 | + headers={oauthRequestCredentials.headers} | |
| 245 | + queryParams={oauthRequestCredentials.queryParams} | |
| 246 | + isConnected={isConnected} | |
| 247 | + onConnected={async (connectionId) => { | |
| 248 | + await setBinding({ | |
| 249 | + params: { scopeId: oauthCredentialTargetScope }, | |
| 250 | + payload: new GraphqlSourceBindingInput({ | |
| 251 | + sourceId: props.sourceId, | |
| 252 | + sourceScope, | |
| 253 | + scope: oauthCredentialTargetScope, | |
| 254 | + slot: oauth2.connectionSlot, | |
| 255 | + value: { kind: "connection", connectionId }, | |
| 256 | + }), | |
| 257 | + reactivityKeys: [...sourceWriteKeys, ...connectionWriteKeys], | |
| 258 | + }); | |
| 259 | + }} | |
| 260 | + /> | |
| 261 | + )} | |
| 262 | + | |
| 227 | 263 | {error && ( |
| 228 | 264 | <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2"> |
| 229 | 265 | <p className="text-sm text-destructive">{error}</p> |
| @@ -0,0 +1,42 @@ | ||
| 1 | +import { | |
| 2 | + CardStack, | |
| 3 | + CardStackContent, | |
| 4 | + CardStackEntryField, | |
| 5 | +} from "@executor-js/react/components/card-stack"; | |
| 6 | +import { Input } from "@executor-js/react/components/input"; | |
| 7 | +import { | |
| 8 | + SourceIdentityFieldRows, | |
| 9 | + type SourceIdentity, | |
| 10 | +} from "@executor-js/react/plugins/source-identity"; | |
| 11 | + | |
| 12 | +export function GraphqlSourceFields(props: { | |
| 13 | + readonly endpoint: string; | |
| 14 | + readonly onEndpointChange: (endpoint: string) => void; | |
| 15 | + readonly identity: SourceIdentity; | |
| 16 | + readonly endpointDisabled?: boolean; | |
| 17 | + readonly namespaceReadOnly?: boolean; | |
| 18 | +}) { | |
| 19 | + return ( | |
| 20 | + <CardStack> | |
| 21 | + <CardStackContent className="border-t-0"> | |
| 22 | + <CardStackEntryField | |
| 23 | + label="Endpoint" | |
| 24 | + hint="The endpoint will be introspected to discover available queries and mutations." | |
| 25 | + > | |
| 26 | + <Input | |
| 27 | + value={props.endpoint} | |
| 28 | + onChange={(e) => props.onEndpointChange((e.target as HTMLInputElement).value)} | |
| 29 | + placeholder="https://api.example.com/graphql" | |
| 30 | + className="font-mono text-sm" | |
| 31 | + disabled={props.endpointDisabled} | |
| 32 | + /> | |
| 33 | + </CardStackEntryField> | |
| 34 | + <SourceIdentityFieldRows | |
| 35 | + identity={props.identity} | |
| 36 | + namePlaceholder="e.g. Shopify API" | |
| 37 | + namespaceReadOnly={props.namespaceReadOnly} | |
| 38 | + /> | |
| 39 | + </CardStackContent> | |
| 40 | + </CardStack> | |
| 41 | + ); | |
| 42 | +} | |
| @@ -1,7 +1,76 @@ | ||
| 1 | +import { useAtomValue } from "@effect/atom-react"; | |
| 2 | +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | |
| 3 | + | |
| 4 | +import { connectionsAtom } from "@executor-js/react/api/atoms"; | |
| 5 | +import { useScope, useScopeStack, useUserScope } from "@executor-js/react/api/scope-context"; | |
| 6 | +import { | |
| 7 | + SourceCredentialNotice, | |
| 8 | + SourceCredentialStatusBadge, | |
| 9 | + missingSourceCredentialLabels, | |
| 10 | + type SourceCredentialSlot, | |
| 11 | +} from "@executor-js/react/plugins/source-credential-status"; | |
| 12 | +import { ScopeId } from "@executor-js/sdk/core"; | |
| 13 | + | |
| 14 | +import { graphqlSourceAtom, graphqlSourceBindingsAtom } from "./atoms"; | |
| 15 | +import type { StoredGraphqlSource } from "../sdk/store"; | |
| 16 | + | |
| 17 | +const sourceCredentialSlots = (source: StoredGraphqlSource): readonly SourceCredentialSlot[] => { | |
| 18 | + const slots: SourceCredentialSlot[] = []; | |
| 19 | + for (const [name, value] of Object.entries(source.headers)) { | |
| 20 | + if (typeof value !== "string") slots.push({ kind: "secret", slot: value.slot, label: name }); | |
| 21 | + } | |
| 22 | + for (const [name, value] of Object.entries(source.queryParams)) { | |
| 23 | + if (typeof value !== "string") slots.push({ kind: "secret", slot: value.slot, label: name }); | |
| 24 | + } | |
| 25 | + if (source.auth.kind === "oauth2") { | |
| 26 | + slots.push({ | |
| 27 | + kind: "connection", | |
| 28 | + slot: source.auth.connectionSlot, | |
| 29 | + label: "OAuth sign-in", | |
| 30 | + }); | |
| 31 | + } | |
| 32 | + return slots; | |
| 33 | +}; | |
| 34 | + | |
| 1 | 35 | export default function GraphqlSourceSummary(props: { |
| 2 | 36 | sourceId: string; |
| 3 | 37 | variant?: "badge" | "panel"; |
| 38 | + onAction?: () => void; | |
| 4 | 39 | }) { |
| 5 | − if (props.variant === "panel") return null; | |
| 6 | − return <span>GraphQL · {props.sourceId}</span>; | |
| 40 | + const displayScope = useScope(); | |
| 41 | + const userScope = useUserScope(); | |
| 42 | + const scopeStack = useScopeStack(); | |
| 43 | + const sourceResult = useAtomValue(graphqlSourceAtom(displayScope, props.sourceId)); | |
| 44 | + const source = | |
| 45 | + AsyncResult.isSuccess(sourceResult) && sourceResult.value ? sourceResult.value : null; | |
| 46 | + const sourceScope = source ? ScopeId.make(source.scope) : displayScope; | |
| 47 | + const bindingsResult = useAtomValue( | |
| 48 | + graphqlSourceBindingsAtom(displayScope, props.sourceId, sourceScope), | |
| 49 | + ); | |
| 50 | + const connectionsResult = useAtomValue(connectionsAtom(displayScope)); | |
| 51 | + | |
| 52 | + if (!source) return null; | |
| 53 | + const slots = sourceCredentialSlots(source as StoredGraphqlSource); | |
| 54 | + if (slots.length === 0) return null; | |
| 55 | + if (!AsyncResult.isSuccess(bindingsResult) || !AsyncResult.isSuccess(connectionsResult)) { | |
| 56 | + return props.variant === "panel" ? null : ( | |
| 57 | + <SourceCredentialStatusBadge missing={["credentials"]} /> | |
| 58 | + ); | |
| 59 | + } | |
| 60 | + | |
| 61 | + const scopeRanks = new Map(scopeStack.map((scope, index) => [scope.id, index] as const)); | |
| 62 | + const liveConnectionIds = new Set(connectionsResult.value.map((connection) => connection.id)); | |
| 63 | + const missing = missingSourceCredentialLabels({ | |
| 64 | + slots, | |
| 65 | + bindings: bindingsResult.value, | |
| 66 | + targetScope: userScope, | |
| 67 | + scopeRanks, | |
| 68 | + liveConnectionIds, | |
| 69 | + }); | |
| 70 | + | |
| 71 | + if (props.variant === "panel") { | |
| 72 | + return <SourceCredentialNotice missing={missing} onAction={props.onAction} />; | |
| 73 | + } | |
| 74 | + | |
| 75 | + return <SourceCredentialStatusBadge missing={missing} />; | |
| 7 | 76 | } |
| @@ -5,7 +5,6 @@ import { graphqlPresets } from "../sdk/presets"; · 4 unmodified lines | ||
| 5 | 5 | const importAdd = () => import("./AddGraphqlSource"); |
| 6 | 6 | const importEdit = () => import("./EditGraphqlSource"); |
| 7 | 7 | const importSummary = () => import("./GraphqlSourceSummary"); |
| 8 | −const importSignIn = () => import("./GraphqlSignInButton"); | |
| 9 | 8 | |
| 10 | 9 | export const graphqlSourcePlugin: SourcePlugin = { |
| 11 | 10 | key: "graphql", |
| @@ -13,12 +12,10 @@ export const graphqlSourcePlugin: SourcePlugin = { · 1 unmodified line | ||
| 13 | 12 | add: lazy(importAdd), |
| 14 | 13 | edit: lazy(importEdit), |
| 15 | 14 | summary: lazy(importSummary), |
| 16 | − signIn: lazy(importSignIn), | |
| 17 | 15 | presets: graphqlPresets, |
| 18 | 16 | preload: () => { |
| 19 | 17 | void importAdd(); |
| 20 | 18 | void importEdit(); |
| 21 | 19 | void importSummary(); |
| 22 | − void importSignIn(); | |
| 23 | 20 | }, |
| 24 | 21 | }; |
| @@ -10,22 +10,14 @@ import { · 9 unmodified lines | ||
| 10 | 10 | CardStack, |
| 11 | 11 | CardStackContent, |
| 12 | 12 | CardStackEntry, |
| 13 | − CardStackEntryActions, | |
| 14 | − CardStackEntryContent, | |
| 15 | − CardStackEntryDescription, | |
| 16 | 13 | CardStackEntryField, |
| 17 | − CardStackEntryMedia, | |
| 18 | − CardStackEntryTitle, | |
| 19 | 14 | } from "@executor-js/react/components/card-stack"; |
| 20 | −import { FieldError, FieldLabel } from "@executor-js/react/components/field"; | |
| 15 | +import { FieldLabel } from "@executor-js/react/components/field"; | |
| 21 | 16 | import { FilterTabs } from "@executor-js/react/components/filter-tabs"; |
| 22 | 17 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 23 | 18 | import { Input } from "@executor-js/react/components/input"; |
| 24 | 19 | import { Label } from "@executor-js/react/components/label"; |
| 25 | −import { Badge } from "@executor-js/react/components/badge"; | |
| 26 | −import { Skeleton } from "@executor-js/react/components/skeleton"; | |
| 27 | −import { SourceFavicon } from "@executor-js/react/components/source-favicon"; | |
| 28 | −import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; | |
| 20 | +import { Spinner } from "@executor-js/react/components/spinner"; | |
| 29 | 21 | import { Textarea } from "@executor-js/react/components/textarea"; |
| 30 | 22 | import { |
| 31 | 23 | emptyHttpCredentials, |
| @@ -37,7 +29,6 @@ import { · 5 unmodified lines | ||
| 37 | 29 | import { |
| 38 | 30 | sourceDisplayNameFromUrl, |
| 39 | 31 | slugifyNamespace, |
| 40 | − SourceIdentityFieldRows, | |
| 41 | 32 | SourceIdentityFields, |
| 42 | 33 | useSourceIdentity, |
| 43 | 34 | } from "@executor-js/react/plugins/source-identity"; |
| @@ -57,6 +48,7 @@ import { · 13 unmodified lines | ||
| 57 | 48 | type RemoteAuthMode = "none" | "oauth2"; |
| 58 | 49 | import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 59 | 50 | import { probeMcpEndpoint, addMcpSourceOptimistic } from "./atoms"; |
| 51 | +import { McpRemoteSourceFields } from "./McpRemoteSourceFields"; | |
| 60 | 52 | import { mcpPresets, type McpPreset } from "../sdk/presets"; |
| 61 | 53 | import { MCP_OAUTH_CONNECTION_SLOT, type McpCredentialInput } from "../sdk/types"; |
| 62 | 54 | |
| @@ -599,116 +591,15 @@ export default function AddMcpSource(props: { · 536 unmodified lines | ||
| 599 | 591 | |
| 600 | 592 | {transport === "remote" ? ( |
| 601 | 593 | <> |
| 602 | − {/* Server info card (shown above URL input after probing) */} | |
| 603 | − {probe ? ( | |
| 604 | − <CardStack> | |
| 605 | − <CardStackContent className="border-t-0"> | |
| 606 | − <CardStackEntry> | |
| 607 | − <CardStackEntryMedia> | |
| 608 | − <SourceFavicon url={state.url} size={32} /> | |
| 609 | − </CardStackEntryMedia> | |
| 610 | − <CardStackEntryContent> | |
| 611 | − <CardStackEntryTitle>{probe.serverName ?? probe.name}</CardStackEntryTitle> | |
| 612 | − <CardStackEntryDescription> | |
| 613 | − {probe.connected | |
| 614 | − ? `${probe.toolCount} tool${probe.toolCount !== 1 ? "s" : ""} available` | |
| 615 | − : "OAuth required to discover tools"} | |
| 616 | − </CardStackEntryDescription> | |
| 617 | − </CardStackEntryContent> | |
| 618 | − <CardStackEntryActions> | |
| 619 | − {probe.connected ? ( | |
| 620 | − <Badge | |
| 621 | − variant="outline" | |
| 622 | − className="border-emerald-500/20 bg-emerald-500/10 text-[10px] text-emerald-600 dark:text-emerald-400" | |
| 623 | − > | |
| 624 | − Connected | |
| 625 | − </Badge> | |
| 626 | − ) : ( | |
| 627 | − <Badge | |
| 628 | − variant="outline" | |
| 629 | − className="border-amber-500/20 bg-amber-500/10 text-[10px] text-amber-600 dark:text-amber-400" | |
| 630 | − > | |
| 631 | − OAuth required | |
| 632 | − </Badge> | |
| 633 | − )} | |
| 634 | − </CardStackEntryActions> | |
| 635 | − </CardStackEntry> | |
| 636 | − <SourceIdentityFieldRows identity={remoteIdentity} namePlaceholder="e.g. Linear" /> | |
| 637 | − <CardStackEntryField label="Server URL"> | |
| 638 | − <Input | |
| 639 | − value={state.url} | |
| 640 | − onChange={(e) => | |
| 641 | − dispatch({ | |
| 642 | − type: "set-url", | |
| 643 | − url: (e.target as HTMLInputElement).value, | |
| 644 | − }) | |
| 645 | − } | |
| 646 | − placeholder="https://mcp.example.com" | |
| 647 | − className="w-full font-mono text-sm" | |
| 648 | − /> | |
| 649 | − </CardStackEntryField> | |
| 650 | − </CardStackContent> | |
| 651 | − </CardStack> | |
| 652 | − ) : isProbing ? ( | |
| 653 | − <CardStack> | |
| 654 | − <CardStackContent className="border-t-0"> | |
| 655 | − <CardStackEntry> | |
| 656 | − <CardStackEntryMedia> | |
| 657 | − <Skeleton className="size-4 rounded" /> | |
| 658 | − </CardStackEntryMedia> | |
| 659 | − <CardStackEntryContent> | |
| 660 | − <Skeleton className="h-4 w-40" /> | |
| 661 | − <Skeleton className="mt-1 h-3 w-32" /> | |
| 662 | − </CardStackEntryContent> | |
| 663 | − <CardStackEntryActions> | |
| 664 | − <Skeleton className="h-4 w-20 rounded-full" /> | |
| 665 | − </CardStackEntryActions> | |
| 666 | − </CardStackEntry> | |
| 667 | − </CardStackContent> | |
| 668 | − </CardStack> | |
| 669 | − ) : null} | |
| 670 | − | |
| 671 | − {!probe && ( | |
| 672 | − <CardStack> | |
| 673 | − <CardStackContent className="border-t-0"> | |
| 674 | − <CardStackEntryField label="Server URL"> | |
| 675 | − <div className="relative"> | |
| 676 | − <Input | |
| 677 | − value={state.url} | |
| 678 | − onChange={(e) => | |
| 679 | − dispatch({ | |
| 680 | − type: "set-url", | |
| 681 | − url: (e.target as HTMLInputElement).value, | |
| 682 | − }) | |
| 683 | − } | |
| 684 | − placeholder="https://mcp.example.com" | |
| 685 | − className="w-full pr-9 font-mono text-sm" | |
| 686 | − aria-invalid={probeError ? true : undefined} | |
| 687 | − /> | |
| 688 | − {isProbing && ( | |
| 689 | − <div className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2"> | |
| 690 | − <IOSSpinner className="size-4" /> | |
| 691 | − </div> | |
| 692 | − )} | |
| 693 | − </div> | |
| 694 | − {probeError && ( | |
| 695 | − <div className="mt-2 space-y-2"> | |
| 696 | − <FieldError>{probeError}</FieldError> | |
| 697 | − <Button | |
| 698 | − type="button" | |
| 699 | − variant="outline" | |
| 700 | − size="sm" | |
| 701 | − onClick={handleProbe} | |
| 702 | − className="h-7 px-2 text-xs" | |
| 703 | − > | |
| 704 | − Try again | |
| 705 | − </Button> | |
| 706 | − </div> | |
| 707 | − )} | |
| 708 | − </CardStackEntryField> | |
| 709 | − </CardStackContent> | |
| 710 | − </CardStack> | |
| 711 | − )} | |
| 594 | + <McpRemoteSourceFields | |
| 595 | + url={state.url} | |
| 596 | + onUrlChange={(url) => dispatch({ type: "set-url", url })} | |
| 597 | + identity={remoteIdentity} | |
| 598 | + preview={probe} | |
| 599 | + probing={isProbing} | |
| 600 | + error={probeError} | |
| 601 | + onRetry={handleProbe} | |
| 602 | + /> | |
| 712 | 603 | |
| 713 | 604 | <HttpCredentialsEditor |
| 714 | 605 | credentials={remoteCredentials} |
| @@ -2,37 +2,39 @@ import { useState } from "react"; · 1 unmodified line | ||
| 2 | 2 | import { useAtomValue, useAtomSet } from "@effect/atom-react"; |
| 3 | 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 4 | 4 | import * as Exit from "effect/Exit"; |
| 5 | −import { mcpSourceAtom, mcpSourceBindingsAtom, updateMcpSource } from "./atoms"; | |
| 6 | −import { useScope } from "@executor-js/react/api/scope-context"; | |
| 7 | −import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; | |
| 8 | 5 | import { |
| 9 | − SourceIdentityFields, | |
| 10 | − useSourceIdentity, | |
| 11 | −} from "@executor-js/react/plugins/source-identity"; | |
| 12 | −import { | |
| 13 | − CredentialTargetScopeSelector, | |
| 14 | − useCredentialTargetScope, | |
| 15 | −} from "@executor-js/react/plugins/credential-target-scope"; | |
| 6 | + mcpSourceAtom, | |
| 7 | + mcpSourceBindingsAtom, | |
| 8 | + setMcpSourceBinding, | |
| 9 | + updateMcpSource, | |
| 10 | +} from "./atoms"; | |
| 11 | +import { connectionsAtom } from "@executor-js/react/api/atoms"; | |
| 12 | +import { useScope, useScopeStack } from "@executor-js/react/api/scope-context"; | |
| 13 | +import { connectionWriteKeys, sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; | |
| 14 | +import { slugifyNamespace, useSourceIdentity } from "@executor-js/react/plugins/source-identity"; | |
| 15 | +import { useCredentialTargetScope } from "@executor-js/react/plugins/credential-target-scope"; | |
| 16 | 16 | import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets"; |
| 17 | 17 | import { |
| 18 | 18 | HttpCredentialsEditor, |
| 19 | + serializeHttpCredentials, | |
| 19 | 20 | serializeScopedHttpCredentials, |
| 20 | 21 | type HttpCredentialsState, |
| 21 | 22 | } from "@executor-js/react/plugins/http-credentials"; |
| 22 | 23 | import { |
| 24 | + effectiveCredentialBindingForScope, | |
| 23 | 25 | httpCredentialsFromConfiguredCredentialBindings, |
| 24 | 26 | initialCredentialTargetScope, |
| 25 | 27 | } from "@executor-js/react/plugins/credential-bindings"; |
| 28 | +import { SourceOAuthConnectionControl } from "@executor-js/react/plugins/source-oauth-connection"; | |
| 26 | 29 | import { Button } from "@executor-js/react/components/button"; |
| 27 | −import { | |
| 28 | − CardStack, | |
| 29 | − CardStackContent, | |
| 30 | − CardStackEntryField, | |
| 31 | −} from "@executor-js/react/components/card-stack"; | |
| 32 | −import { Input } from "@executor-js/react/components/input"; | |
| 33 | 30 | import { Badge } from "@executor-js/react/components/badge"; |
| 34 | 31 | import { ScopeId } from "@executor-js/sdk/core"; |
| 35 | −import type { McpCredentialInput, McpSourceBindingRef } from "../sdk/types"; | |
| 32 | +import { McpRemoteSourceFields } from "./McpRemoteSourceFields"; | |
| 33 | +import { | |
| 34 | + McpSourceBindingInput, | |
| 35 | + type McpCredentialInput, | |
| 36 | + type McpSourceBindingRef, | |
| 37 | +} from "../sdk/types"; | |
| 36 | 38 | import type { McpStoredSourceSchemaType } from "../sdk/stored-source"; |
| 37 | 39 | |
| 38 | 40 | // --------------------------------------------------------------------------- |
| @@ -46,14 +48,23 @@ function RemoteEditForm(props: { · 7 unmodified lines | ||
| 46 | 48 | onSave: () => void; |
| 47 | 49 | }) { |
| 48 | 50 | const displayScope = useScope(); |
| 51 | + const scopeStack = useScopeStack(); | |
| 49 | 52 | const sourceScope = ScopeId.make(props.initial.scope); |
| 50 | − const { credentialTargetScope, setCredentialTargetScope, credentialScopeOptions } = | |
| 51 | − useCredentialTargetScope({ | |
| 52 | − sourceScope, | |
| 53 | − initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings), | |
| 54 | − }); | |
| 53 | + const { credentialTargetScope, credentialScopeOptions } = useCredentialTargetScope({ | |
| 54 | + sourceScope, | |
| 55 | + initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings), | |
| 56 | + }); | |
| 57 | + const { | |
| 58 | + credentialTargetScope: oauthCredentialTargetScope, | |
| 59 | + setCredentialTargetScope: setOAuthCredentialTargetScope, | |
| 60 | + } = useCredentialTargetScope({ | |
| 61 | + sourceScope, | |
| 62 | + initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings), | |
| 63 | + }); | |
| 55 | 64 | const doUpdate = useAtomSet(updateMcpSource, { mode: "promiseExit" }); |
| 65 | + const setBinding = useAtomSet(setMcpSourceBinding, { mode: "promise" }); | |
| 56 | 66 | const secretList = useSecretPickerSecrets(); |
| 67 | + const connectionsResult = useAtomValue(connectionsAtom(displayScope)); | |
| 57 | 68 | |
| 58 | 69 | const identity = useSourceIdentity({ |
| 59 | 70 | fallbackName: props.initial.name, |
| @@ -74,6 +85,23 @@ function RemoteEditForm(props: { · 14 unmodified lines | ||
| 74 | 85 | const identityDirty = identity.name.trim() !== props.initial.name.trim(); |
| 75 | 86 | const metadataDirty = identityDirty || endpoint.trim() !== props.initial.config.endpoint.trim(); |
| 76 | 87 | const dirty = metadataDirty || credentialsDirty; |
| 88 | + const oauth2 = props.initial.config.auth.kind === "oauth2" ? props.initial.config.auth : null; | |
| 89 | + const connections = AsyncResult.isSuccess(connectionsResult) ? connectionsResult.value : []; | |
| 90 | + const scopeRanks = new Map(scopeStack.map((scope, index) => [scope.id, index] as const)); | |
| 91 | + const connectionBinding = oauth2 | |
| 92 | + ? effectiveCredentialBindingForScope( | |
| 93 | + props.bindings, | |
| 94 | + oauth2.connectionSlot, | |
| 95 | + oauthCredentialTargetScope, | |
| 96 | + scopeRanks, | |
| 97 | + ) | |
| 98 | + : null; | |
| 99 | + const boundConnectionId = | |
| 100 | + connectionBinding?.value.kind === "connection" ? connectionBinding.value.connectionId : null; | |
| 101 | + const isConnected = | |
| 102 | + boundConnectionId !== null && | |
| 103 | + connections.some((connection) => connection.id === boundConnectionId); | |
| 104 | + const oauthRequestCredentials = serializeHttpCredentials(credentials); | |
| 77 | 105 | |
| 78 | 106 | const handleCredentialsChange = (next: HttpCredentialsState) => { |
| 79 | 107 | setCredentials(next); |
| @@ -137,32 +165,17 @@ function RemoteEditForm(props: { · 57 unmodified lines | ||
| 137 | 165 | </Badge> |
| 138 | 166 | </div> |
| 139 | 167 | |
| 140 | − <SourceIdentityFields identity={identity} namespaceReadOnly /> | |
| 141 | − | |
| 142 | − {/* Endpoint */} | |
| 143 | − <CardStack> | |
| 144 | − <CardStackContent className="border-t-0"> | |
| 145 | − <CardStackEntryField label="Endpoint"> | |
| 146 | − <Input | |
| 147 | − value={endpoint} | |
| 148 | − onChange={(e) => { | |
| 149 | − setEndpoint((e.target as HTMLInputElement).value); | |
| 150 | − }} | |
| 151 | − placeholder="https://mcp.example.com" | |
| 152 | − className="font-mono text-sm" | |
| 153 | − /> | |
| 154 | − </CardStackEntryField> | |
| 155 | − </CardStackContent> | |
| 156 | − </CardStack> | |
| 157 | − | |
| 158 | − <CredentialTargetScopeSelector | |
| 159 | − value={credentialTargetScope} | |
| 160 | − options={credentialScopeOptions} | |
| 161 | − onChange={(targetScope) => { | |
| 162 | − setCredentialTargetScope(targetScope); | |
| 163 | − setCredentialsDirty(true); | |
| 168 | + <McpRemoteSourceFields | |
| 169 | + url={endpoint} | |
| 170 | + onUrlChange={setEndpoint} | |
| 171 | + identity={identity} | |
| 172 | + preview={{ | |
| 173 | + name: props.initial.name, | |
| 174 | + serverName: props.initial.name, | |
| 175 | + connected: true, | |
| 176 | + toolCount: null, | |
| 164 | 177 | }} |
| 165 | − description="Choose where updated MCP credentials are saved." | |
| 178 | + namespaceReadOnly | |
| 166 | 179 | /> |
| 167 | 180 | |
| 168 | 181 | <HttpCredentialsEditor |
| @@ -175,6 +188,39 @@ function RemoteEditForm(props: { · 6 unmodified lines | ||
| 175 | 188 | bindingScopeOptions={credentialScopeOptions} |
| 176 | 189 | /> |
| 177 | 190 | |
| 191 | + {oauth2 && ( | |
| 192 | + <SourceOAuthConnectionControl | |
| 193 | + popupName="mcp-oauth" | |
| 194 | + pluginId="mcp" | |
| 195 | + namespace={slugifyNamespace(props.initial.namespace) || "mcp"} | |
| 196 | + fallbackNamespace="mcp" | |
| 197 | + endpoint={endpoint.trim()} | |
| 198 | + tokenScope={oauthCredentialTargetScope} | |
| 199 | + onTokenScopeChange={setOAuthCredentialTargetScope} | |
| 200 | + credentialScopeOptions={credentialScopeOptions} | |
| 201 | + connectionId={boundConnectionId} | |
| 202 | + sourceLabel={`${identity.name.trim() || props.initial.namespace || "MCP"} OAuth`} | |
| 203 | + headers={oauthRequestCredentials.headers} | |
| 204 | + queryParams={oauthRequestCredentials.queryParams} | |
| 205 | + isConnected={isConnected} | |
| 206 | + onConnected={async (connectionId) => { | |
| 207 | + await setBinding({ | |
| 208 | + params: { scopeId: oauthCredentialTargetScope }, | |
| 209 | + payload: new McpSourceBindingInput({ | |
| 210 | + sourceId: props.sourceId, | |
| 211 | + sourceScope, | |
| 212 | + scope: oauthCredentialTargetScope, | |
| 213 | + slot: oauth2.connectionSlot, | |
| 214 | + value: { kind: "connection", connectionId }, | |
| 215 | + }), | |
| 216 | + reactivityKeys: [...sourceWriteKeys, ...connectionWriteKeys], | |
| 217 | + }); | |
| 218 | + }} | |
| 219 | + reconnectingLabel="Reconnecting…" | |
| 220 | + signingInLabel="Signing in…" | |
| 221 | + /> | |
| 222 | + )} | |
| 223 | + | |
| 178 | 224 | {error && ( |
| 179 | 225 | <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2"> |
| 180 | 226 | <p className="text-sm text-destructive">{error}</p> |
| @@ -0,0 +1,163 @@ | ||
| 1 | +import { Badge } from "@executor-js/react/components/badge"; | |
| 2 | +import { | |
| 3 | + CardStack, | |
| 4 | + CardStackContent, | |
| 5 | + CardStackEntry, | |
| 6 | + CardStackEntryActions, | |
| 7 | + CardStackEntryContent, | |
| 8 | + CardStackEntryDescription, | |
| 9 | + CardStackEntryField, | |
| 10 | + CardStackEntryMedia, | |
| 11 | + CardStackEntryTitle, | |
| 12 | +} from "@executor-js/react/components/card-stack"; | |
| 13 | +import { FieldError } from "@executor-js/react/components/field"; | |
| 14 | +import { Input } from "@executor-js/react/components/input"; | |
| 15 | +import { Skeleton } from "@executor-js/react/components/skeleton"; | |
| 16 | +import { SourceFavicon } from "@executor-js/react/components/source-favicon"; | |
| 17 | +import { IOSSpinner } from "@executor-js/react/components/spinner"; | |
| 18 | +import { Button } from "@executor-js/react/components/button"; | |
| 19 | +import { | |
| 20 | + SourceIdentityFieldRows, | |
| 21 | + type SourceIdentity, | |
| 22 | +} from "@executor-js/react/plugins/source-identity"; | |
| 23 | + | |
| 24 | +export type McpRemoteSourcePreview = { | |
| 25 | + readonly name: string; | |
| 26 | + readonly serverName: string | null; | |
| 27 | + readonly connected: boolean; | |
| 28 | + readonly toolCount: number | null; | |
| 29 | +}; | |
| 30 | + | |
| 31 | +export function McpRemoteSourceFields(props: { | |
| 32 | + readonly url: string; | |
| 33 | + readonly onUrlChange: (url: string) => void; | |
| 34 | + readonly identity: SourceIdentity; | |
| 35 | + readonly preview: McpRemoteSourcePreview | null; | |
| 36 | + readonly probing?: boolean; | |
| 37 | + readonly error?: string | null; | |
| 38 | + readonly onRetry?: () => void; | |
| 39 | + readonly namespaceReadOnly?: boolean; | |
| 40 | + readonly urlDisabled?: boolean; | |
| 41 | +}) { | |
| 42 | + const previewDescription = props.preview | |
| 43 | + ? props.preview.connected | |
| 44 | + ? props.preview.toolCount === null | |
| 45 | + ? null | |
| 46 | + : `${props.preview.toolCount} tool${props.preview.toolCount !== 1 ? "s" : ""} available` | |
| 47 | + : "OAuth required to discover tools" | |
| 48 | + : null; | |
| 49 | + | |
| 50 | + if (props.preview) { | |
| 51 | + return ( | |
| 52 | + <CardStack> | |
| 53 | + <CardStackContent className="border-t-0"> | |
| 54 | + <CardStackEntry> | |
| 55 | + <CardStackEntryMedia> | |
| 56 | + <SourceFavicon url={props.url} size={32} /> | |
| 57 | + </CardStackEntryMedia> | |
| 58 | + <CardStackEntryContent> | |
| 59 | + <CardStackEntryTitle> | |
| 60 | + {props.preview.serverName ?? props.preview.name} | |
| 61 | + </CardStackEntryTitle> | |
| 62 | + {previewDescription ? ( | |
| 63 | + <CardStackEntryDescription>{previewDescription}</CardStackEntryDescription> | |
| 64 | + ) : null} | |
| 65 | + </CardStackEntryContent> | |
| 66 | + <CardStackEntryActions> | |
| 67 | + {props.preview.connected ? ( | |
| 68 | + <Badge | |
| 69 | + variant="outline" | |
| 70 | + className="border-emerald-500/20 bg-emerald-500/10 text-[10px] text-emerald-600 dark:text-emerald-400" | |
| 71 | + > | |
| 72 | + Connected | |
| 73 | + </Badge> | |
| 74 | + ) : ( | |
| 75 | + <Badge | |
| 76 | + variant="outline" | |
| 77 | + className="border-amber-500/20 bg-amber-500/10 text-[10px] text-amber-600 dark:text-amber-400" | |
| 78 | + > | |
| 79 | + OAuth required | |
| 80 | + </Badge> | |
| 81 | + )} | |
| 82 | + </CardStackEntryActions> | |
| 83 | + </CardStackEntry> | |
| 84 | + <SourceIdentityFieldRows | |
| 85 | + identity={props.identity} | |
| 86 | + namePlaceholder="e.g. Linear" | |
| 87 | + namespaceReadOnly={props.namespaceReadOnly} | |
| 88 | + /> | |
| 89 | + <CardStackEntryField label="Server URL"> | |
| 90 | + <Input | |
| 91 | + value={props.url} | |
| 92 | + onChange={(e) => props.onUrlChange((e.target as HTMLInputElement).value)} | |
| 93 | + placeholder="https://mcp.example.com" | |
| 94 | + className="w-full font-mono text-sm" | |
| 95 | + disabled={props.urlDisabled} | |
| 96 | + /> | |
| 97 | + </CardStackEntryField> | |
| 98 | + </CardStackContent> | |
| 99 | + </CardStack> | |
| 100 | + ); | |
| 101 | + } | |
| 102 | + | |
| 103 | + if (props.probing) { | |
| 104 | + return ( | |
| 105 | + <CardStack> | |
| 106 | + <CardStackContent className="border-t-0"> | |
| 107 | + <CardStackEntry> | |
| 108 | + <CardStackEntryMedia> | |
| 109 | + <Skeleton className="size-4 rounded" /> | |
| 110 | + </CardStackEntryMedia> | |
| 111 | + <CardStackEntryContent> | |
| 112 | + <Skeleton className="h-4 w-40" /> | |
| 113 | + <Skeleton className="mt-1 h-3 w-32" /> | |
| 114 | + </CardStackEntryContent> | |
| 115 | + <CardStackEntryActions> | |
| 116 | + <Skeleton className="h-4 w-20 rounded-full" /> | |
| 117 | + </CardStackEntryActions> | |
| 118 | + </CardStackEntry> | |
| 119 | + </CardStackContent> | |
| 120 | + </CardStack> | |
| 121 | + ); | |
| 122 | + } | |
| 123 | + | |
| 124 | + return ( | |
| 125 | + <CardStack> | |
| 126 | + <CardStackContent className="border-t-0"> | |
| 127 | + <CardStackEntryField label="Server URL"> | |
| 128 | + <div className="relative"> | |
| 129 | + <Input | |
| 130 | + value={props.url} | |
| 131 | + onChange={(e) => props.onUrlChange((e.target as HTMLInputElement).value)} | |
| 132 | + placeholder="https://mcp.example.com" | |
| 133 | + className="w-full pr-9 font-mono text-sm" | |
| 134 | + aria-invalid={props.error ? true : undefined} | |
| 135 | + disabled={props.urlDisabled} | |
| 136 | + /> | |
| 137 | + {props.probing && ( | |
| 138 | + <div className="pointer-events-none absolute right-2 top-1/2 -translate-y-1/2"> | |
| 139 | + <IOSSpinner className="size-4" /> | |
| 140 | + </div> | |
| 141 | + )} | |
| 142 | + </div> | |
| 143 | + {props.error && ( | |
| 144 | + <div className="mt-2 space-y-2"> | |
| 145 | + <FieldError>{props.error}</FieldError> | |
| 146 | + {props.onRetry && ( | |
| 147 | + <Button | |
| 148 | + type="button" | |
| 149 | + variant="outline" | |
| 150 | + size="sm" | |
| 151 | + onClick={props.onRetry} | |
| 152 | + className="h-7 px-2 text-xs" | |
| 153 | + > | |
| 154 | + Try again | |
| 155 | + </Button> | |
| 156 | + )} | |
| 157 | + </div> | |
| 158 | + )} | |
| 159 | + </CardStackEntryField> | |
| 160 | + </CardStackContent> | |
| 161 | + </CardStack> | |
| 162 | + ); | |
| 163 | +} | |
| @@ -0,0 +1,93 @@ | ||
| 1 | +import { useAtomValue } from "@effect/atom-react"; | |
| 2 | +import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | |
| 3 | + | |
| 4 | +import { connectionsAtom } from "@executor-js/react/api/atoms"; | |
| 5 | +import { useScope, useScopeStack, useUserScope } from "@executor-js/react/api/scope-context"; | |
| 6 | +import { | |
| 7 | + SourceCredentialNotice, | |
| 8 | + SourceCredentialStatusBadge, | |
| 9 | + missingSourceCredentialLabels, | |
| 10 | + type SourceCredentialSlot, | |
| 11 | +} from "@executor-js/react/plugins/source-credential-status"; | |
| 12 | +import { ScopeId } from "@executor-js/sdk/core"; | |
| 13 | + | |
| 14 | +import { mcpSourceAtom, mcpSourceBindingsAtom } from "./atoms"; | |
| 15 | +import type { McpStoredSourceSchemaType } from "../sdk/stored-source"; | |
| 16 | + | |
| 17 | +const sourceCredentialSlots = ( | |
| 18 | + source: McpStoredSourceSchemaType, | |
| 19 | +): readonly SourceCredentialSlot[] => { | |
| 20 | + if (source.config.transport !== "remote") return []; | |
| 21 | + const slots: SourceCredentialSlot[] = []; | |
| 22 | + for (const [name, value] of Object.entries(source.config.headers ?? {})) { | |
| 23 | + if (typeof value !== "string") slots.push({ kind: "secret", slot: value.slot, label: name }); | |
| 24 | + } | |
| 25 | + for (const [name, value] of Object.entries(source.config.queryParams ?? {})) { | |
| 26 | + if (typeof value !== "string") slots.push({ kind: "secret", slot: value.slot, label: name }); | |
| 27 | + } | |
| 28 | + const auth = source.config.auth; | |
| 29 | + if (auth.kind === "header") { | |
| 30 | + slots.push({ | |
| 31 | + kind: "secret", | |
| 32 | + slot: auth.secretSlot, | |
| 33 | + label: auth.headerName, | |
| 34 | + }); | |
| 35 | + } | |
| 36 | + if (auth.kind === "oauth2") { | |
| 37 | + if (auth.clientIdSlot) { | |
| 38 | + slots.push({ kind: "secret", slot: auth.clientIdSlot, label: "Client ID" }); | |
| 39 | + } | |
| 40 | + if (auth.clientSecretSlot) { | |
| 41 | + slots.push({ kind: "secret", slot: auth.clientSecretSlot, label: "Client Secret" }); | |
| 42 | + } | |
| 43 | + slots.push({ | |
| 44 | + kind: "connection", | |
| 45 | + slot: auth.connectionSlot, | |
| 46 | + label: "OAuth sign-in", | |
| 47 | + }); | |
| 48 | + } | |
| 49 | + return slots; | |
| 50 | +}; | |
| 51 | + | |
| 52 | +export default function McpSourceSummary(props: { | |
| 53 | + readonly sourceId: string; | |
| 54 | + readonly variant?: "badge" | "panel"; | |
| 55 | + readonly onAction?: () => void; | |
| 56 | +}) { | |
| 57 | + const displayScope = useScope(); | |
| 58 | + const userScope = useUserScope(); | |
| 59 | + const scopeStack = useScopeStack(); | |
| 60 | + const sourceResult = useAtomValue(mcpSourceAtom(displayScope, props.sourceId)); | |
| 61 | + const source = | |
| 62 | + AsyncResult.isSuccess(sourceResult) && sourceResult.value ? sourceResult.value : null; | |
| 63 | + const sourceScope = source ? ScopeId.make(source.scope) : displayScope; | |
| 64 | + const bindingsResult = useAtomValue( | |
| 65 | + mcpSourceBindingsAtom(displayScope, props.sourceId, sourceScope), | |
| 66 | + ); | |
| 67 | + const connectionsResult = useAtomValue(connectionsAtom(displayScope)); | |
| 68 | + | |
| 69 | + if (!source) return null; | |
| 70 | + const slots = sourceCredentialSlots(source as McpStoredSourceSchemaType); | |
| 71 | + if (slots.length === 0) return null; | |
| 72 | + if (!AsyncResult.isSuccess(bindingsResult) || !AsyncResult.isSuccess(connectionsResult)) { | |
| 73 | + return props.variant === "panel" ? null : ( | |
| 74 | + <SourceCredentialStatusBadge missing={["credentials"]} /> | |
| 75 | + ); | |
| 76 | + } | |
| 77 | + | |
| 78 | + const scopeRanks = new Map(scopeStack.map((scope, index) => [scope.id, index] as const)); | |
| 79 | + const liveConnectionIds = new Set(connectionsResult.value.map((connection) => connection.id)); | |
| 80 | + const missing = missingSourceCredentialLabels({ | |
| 81 | + slots, | |
| 82 | + bindings: bindingsResult.value, | |
| 83 | + targetScope: userScope, | |
| 84 | + scopeRanks, | |
| 85 | + liveConnectionIds, | |
| 86 | + }); | |
| 87 | + | |
| 88 | + if (props.variant === "panel") { | |
| 89 | + return <SourceCredentialNotice missing={missing} onAction={props.onAction} />; | |
| 90 | + } | |
| 91 | + | |
| 92 | + return <SourceCredentialStatusBadge missing={missing} />; | |
| 93 | +} | |
| @@ -4,11 +4,11 @@ import { mcpPresets } from "../sdk/presets"; · 3 unmodified lines | ||
| 4 | 4 | |
| 5 | 5 | const importAdd = () => import("./AddMcpSource"); |
| 6 | 6 | const importEdit = () => import("./EditMcpSource"); |
| 7 | −const importSignIn = () => import("./McpSignInButton"); | |
| 7 | +const importSummary = () => import("./McpSourceSummary"); | |
| 8 | 8 | |
| 9 | 9 | const LazyAddMcpSource = lazy(importAdd); |
| 10 | 10 | const LazyEditMcpSource = lazy(importEdit); |
| 11 | −const LazyMcpSignInButton = lazy(importSignIn); | |
| 11 | +const LazyMcpSourceSummary = lazy(importSummary); | |
| 12 | 12 | |
| 13 | 13 | type AddProps = ComponentProps<SourcePlugin["add"]>; |
| 14 | 14 | |
| @@ -41,12 +41,12 @@ export const createMcpSourcePlugin = (options?: McpSourcePluginOptions): SourceP · 26 unmodified lines | ||
| 41 | 41 | label: "MCP", |
| 42 | 42 | add: AddWithFlag, |
| 43 | 43 | edit: LazyEditMcpSource, |
| 44 | − signIn: LazyMcpSignInButton, | |
| 44 | + summary: LazyMcpSourceSummary, | |
| 45 | 45 | presets, |
| 46 | 46 | preload: () => { |
| 47 | 47 | void importAdd(); |
| 48 | 48 | void importEdit(); |
| 49 | − void importSignIn(); | |
| 49 | + void importSummary(); | |
| 50 | 50 | }, |
| 51 | 51 | }; |
| 52 | 52 | }; |
| @@ -34,11 +34,7 @@ import { · 33 unmodified lines | ||
| 34 | 34 | type HeaderState, |
| 35 | 35 | } from "@executor-js/react/plugins/secret-header-auth"; |
| 36 | 36 | import { CredentialScopeDropdown } from "@executor-js/react/plugins/credential-target-scope"; |
| 37 | −import { | |
| 38 | − slugifyNamespace, | |
| 39 | − SourceIdentityFieldRows, | |
| 40 | − useSourceIdentity, | |
| 41 | −} from "@executor-js/react/plugins/source-identity"; | |
| 37 | +import { slugifyNamespace, useSourceIdentity } from "@executor-js/react/plugins/source-identity"; | |
| 42 | 38 | import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets"; |
| 43 | 39 | import { Button } from "@executor-js/react/components/button"; |
| 44 | 40 | import { CopyButton } from "@executor-js/react/components/copy-button"; |
| @@ -47,27 +43,21 @@ import { · 2 unmodified lines | ||
| 47 | 43 | CollapsibleContent, |
| 48 | 44 | CollapsibleTrigger, |
| 49 | 45 | } from "@executor-js/react/components/collapsible"; |
| 50 | −import { FreeformCombobox } from "@executor-js/react/components/combobox"; | |
| 51 | 46 | import { |
| 52 | 47 | CardStack, |
| 53 | 48 | CardStackContent, |
| 54 | − CardStackEntry, | |
| 55 | − CardStackEntryContent, | |
| 56 | − CardStackEntryDescription, | |
| 57 | 49 | CardStackEntryField, |
| 58 | − CardStackEntryTitle, | |
| 59 | 50 | } from "@executor-js/react/components/card-stack"; |
| 60 | 51 | import { FieldLabel } from "@executor-js/react/components/field"; |
| 61 | 52 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 62 | 53 | import { HelpTooltip } from "@executor-js/react/components/help-tooltip"; |
| 63 | −import { Input } from "@executor-js/react/components/input"; | |
| 64 | 54 | import { Label } from "@executor-js/react/components/label"; |
| 65 | 55 | import { Textarea } from "@executor-js/react/components/textarea"; |
| 66 | 56 | import { Checkbox } from "@executor-js/react/components/checkbox"; |
| 67 | −import { SourceFavicon } from "@executor-js/react/components/source-favicon"; | |
| 68 | 57 | import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group"; |
| 69 | 58 | import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; |
| 70 | 59 | import { addOpenApiSpecOptimistic, previewOpenApiSpec, setOpenApiSourceBinding } from "./atoms"; |
| 60 | +import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields"; | |
| 71 | 61 | import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview"; |
| 72 | 62 | import { |
| 73 | 63 | headerBindingSlot, |
| @@ -936,61 +926,32 @@ export default function AddOpenApiSource(props: { · 862 unmodified lines | ||
| 936 | 926 | |
| 937 | 927 | {/* ── Source information card (shown after analysis) ── */} |
| 938 | 928 | {preview ? ( |
| 939 | − <CardStack> | |
| 940 | − <CardStackContent className="border-t-0"> | |
| 941 | − <CardStackEntry> | |
| 942 | − {resolvedBaseUrl && <SourceFavicon url={resolvedBaseUrl} size={16} />} | |
| 943 | − <CardStackEntryContent> | |
| 944 | − <CardStackEntryTitle> | |
| 945 | − {Option.getOrElse(preview.title, () => "API")} | |
| 946 | − </CardStackEntryTitle> | |
| 947 | − <CardStackEntryDescription> | |
| 948 | − {Option.getOrElse(preview.version, () => "")} | |
| 949 | − {Option.isSome(preview.version) && " · "} | |
| 950 | − {preview.operationCount} operation | |
| 951 | − {preview.operationCount !== 1 ? "s" : ""} | |
| 952 | − {preview.tags.length > 0 && | |
| 953 | − ` · ${preview.tags.length} tag${preview.tags.length !== 1 ? "s" : ""}`} | |
| 954 | − </CardStackEntryDescription> | |
| 955 | − </CardStackEntryContent> | |
| 956 | − </CardStackEntry> | |
| 957 | − <SourceIdentityFieldRows identity={identity} /> | |
| 958 | − <div className="grid grid-cols-1 md:grid-cols-2"> | |
| 959 | − <CardStackEntryField label="Base URL"> | |
| 960 | − <FreeformCombobox | |
| 961 | − value={resolvedBaseUrl} | |
| 962 | − onValueChange={setBaseUrl} | |
| 963 | − options={baseUrlOptions} | |
| 964 | − placeholder="https://api.example.com" | |
| 965 | − className="w-full" | |
| 966 | − inputClassName="font-mono text-sm" | |
| 967 | − /> | |
| 968 | − | |
| 969 | − {!resolvedBaseUrl && ( | |
| 970 | − <p className="text-[11px] text-amber-600 dark:text-amber-400"> | |
| 971 | − A base URL is required to make requests. | |
| 972 | − </p> | |
| 973 | − )} | |
| 974 | − </CardStackEntryField> | |
| 975 | − <CardStackEntryField label="Spec URL"> | |
| 976 | − <Input | |
| 977 | − value={specUrl} | |
| 978 | − onChange={(e) => { | |
| 979 | − setSpecUrl((e.target as HTMLInputElement).value); | |
| 980 | − setPreview(null); | |
| 981 | − setBaseUrl(""); | |
| 982 | − setCustomHeaders([]); | |
| 983 | − setStrategy({ kind: "none" }); | |
| 984 | − setOauth2AuthState(null); | |
| 985 | − setOauth2Error(null); | |
| 986 | − }} | |
| 987 | − placeholder="https://api.example.com/openapi.json" | |
| 988 | − className="font-mono text-sm" | |
| 989 | − /> | |
| 990 | − </CardStackEntryField> | |
| 991 | − </div> | |
| 992 | − </CardStackContent> | |
| 993 | − </CardStack> | |
| 929 | + <OpenApiSourceDetailsFields | |
| 930 | + title={Option.getOrElse(preview.title, () => "API")} | |
| 931 | + description={`${Option.getOrElse(preview.version, () => "")}${ | |
| 932 | + Option.isSome(preview.version) ? " · " : "" | |
| 933 | + }${preview.operationCount} operation${preview.operationCount !== 1 ? "s" : ""}${ | |
| 934 | + preview.tags.length > 0 | |
| 935 | + ? ` · ${preview.tags.length} tag${preview.tags.length !== 1 ? "s" : ""}` | |
| 936 | + : "" | |
| 937 | + }`} | |
| 938 | + identity={identity} | |
| 939 | + baseUrl={resolvedBaseUrl} | |
| 940 | + onBaseUrlChange={setBaseUrl} | |
| 941 | + baseUrlOptions={baseUrlOptions} | |
| 942 | + specUrl={specUrl} | |
| 943 | + onSpecUrlChange={(value) => { | |
| 944 | + setSpecUrl(value); | |
| 945 | + setPreview(null); | |
| 946 | + setBaseUrl(""); | |
| 947 | + setCustomHeaders([]); | |
| 948 | + setStrategy({ kind: "none" }); | |
| 949 | + setOauth2AuthState(null); | |
| 950 | + setOauth2Error(null); | |
| 951 | + }} | |
| 952 | + faviconUrl={resolvedBaseUrl} | |
| 953 | + baseUrlMissingMessage="A base URL is required to make requests." | |
| 954 | + /> | |
| 994 | 955 | ) : null} |
| 995 | 956 | |
| 996 | 957 | {analyzeError && ( |
| @@ -44,6 +44,7 @@ import { · 43 unmodified lines | ||
| 44 | 44 | setOpenApiSourceBinding, |
| 45 | 45 | updateOpenApiSource, |
| 46 | 46 | } from "./atoms"; |
| 47 | +import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields"; | |
| 47 | 48 | import { |
| 48 | 49 | OPENAPI_OAUTH_CALLBACK_PATH, |
| 49 | 50 | OPENAPI_OAUTH_POPUP_NAME, |
| @@ -178,6 +179,16 @@ export default function EditOpenApiSource(props: { · 128 unmodified lines | ||
| 178 | 179 | const [oauth2EndpointsSaveState, setOAuth2EndpointsSaveState] = useState< |
| 179 | 180 | "idle" | "saving" | "saved" |
| 180 | 181 | >("idle"); |
| 182 | + const editIdentity = useMemo( | |
| 183 | + () => ({ | |
| 184 | + name, | |
| 185 | + namespace: props.sourceId, | |
| 186 | + setName, | |
| 187 | + setNamespace: () => {}, | |
| 188 | + reset: () => {}, | |
| 189 | + }), | |
| 190 | + [name, props.sourceId], | |
| 191 | + ); | |
| 181 | 192 | const sourceSaveSeq = useRef(0); |
| 182 | 193 | const oauth2EndpointsSaveSeq = useRef(0); |
| 183 | 194 | |
| @@ -580,47 +591,27 @@ export default function EditOpenApiSource(props: { · 396 unmodified lines | ||
| 580 | 591 | <h1 className="text-xl font-semibold text-foreground">OpenAPI Source</h1> |
| 581 | 592 | </div> |
| 582 | 593 | |
| 583 | − <CardStack> | |
| 584 | − <CardStackContent className="border-t-0"> | |
| 585 | − <CardStackEntry> | |
| 586 | − <CardStackEntryContent> | |
| 587 | − <CardStackEntryTitle>Source Details</CardStackEntryTitle> | |
| 588 | − <CardStackEntryDescription> | |
| 589 | − Name and base URL save automatically. | |
| 590 | − </CardStackEntryDescription> | |
| 591 | − </CardStackEntryContent> | |
| 592 | − {sourceSaveState !== "idle" && ( | |
| 593 | − <span className="text-xs text-muted-foreground"> | |
| 594 | − {sourceSaveState === "saving" ? "Saving…" : "Saved"} | |
| 595 | − </span> | |
| 596 | − )} | |
| 597 | − </CardStackEntry> | |
| 598 | − <CardStackEntryField label="Name"> | |
| 599 | − <Input value={name} onChange={(e) => setName((e.target as HTMLInputElement).value)} /> | |
| 600 | − </CardStackEntryField> | |
| 601 | − <CardStackEntryField label="Base URL"> | |
| 602 | − <Input | |
| 603 | − value={baseUrl} | |
| 604 | − onChange={(e) => setBaseUrl((e.target as HTMLInputElement).value)} | |
| 605 | − className="font-mono text-sm" | |
| 606 | − /> | |
| 607 | − </CardStackEntryField> | |
| 608 | − <CardStackEntry> | |
| 609 | − <CardStackEntryContent> | |
| 610 | − <CardStackEntryTitle>Authentication Template</CardStackEntryTitle> | |
| 611 | − <CardStackEntryDescription> | |
| 612 | − {source.config.oauth2 | |
| 613 | − ? `OAuth2 ${source.config.oauth2.flow}` | |
| 614 | − : Object.keys(source.config.headers ?? {}).length > 0 | |
| 615 | − ? `${Object.keys(source.config.headers ?? {}).length} header binding${ | |
| 616 | − Object.keys(source.config.headers ?? {}).length === 1 ? "" : "s" | |
| 617 | − }` | |
| 618 | − : "None"} | |
| 619 | − </CardStackEntryDescription> | |
| 620 | − </CardStackEntryContent> | |
| 621 | − </CardStackEntry> | |
| 622 | − </CardStackContent> | |
| 623 | − </CardStack> | |
| 594 | + <OpenApiSourceDetailsFields | |
| 595 | + title="Source Details" | |
| 596 | + description="Name and base URL save automatically." | |
| 597 | + identity={editIdentity} | |
| 598 | + baseUrl={baseUrl} | |
| 599 | + onBaseUrlChange={setBaseUrl} | |
| 600 | + specUrl={source.config.sourceUrl ?? ""} | |
| 601 | + onSpecUrlChange={() => {}} | |
| 602 | + specUrlDisabled | |
| 603 | + namespaceReadOnly | |
| 604 | + saveState={sourceSaveState} | |
| 605 | + footer={ | |
| 606 | + source.config.oauth2 | |
| 607 | + ? `Authentication Template: OAuth2 ${source.config.oauth2.flow}` | |
| 608 | + : Object.keys(source.config.headers ?? {}).length > 0 | |
| 609 | + ? `Authentication Template: ${Object.keys(source.config.headers ?? {}).length} header binding${ | |
| 610 | + Object.keys(source.config.headers ?? {}).length === 1 ? "" : "s" | |
| 611 | + }` | |
| 612 | + : "Authentication Template: None" | |
| 613 | + } | |
| 614 | + /> | |
| 624 | 615 | |
| 625 | 616 | <CardStack> |
| 626 | 617 | <CardStackContent className="border-t-0"> |
| @@ -0,0 +1,108 @@ | ||
| 1 | +import { | |
| 2 | + CardStack, | |
| 3 | + CardStackContent, | |
| 4 | + CardStackEntry, | |
| 5 | + CardStackEntryContent, | |
| 6 | + CardStackEntryDescription, | |
| 7 | + CardStackEntryField, | |
| 8 | + CardStackEntryTitle, | |
| 9 | +} from "@executor-js/react/components/card-stack"; | |
| 10 | +import { | |
| 11 | + FreeformCombobox, | |
| 12 | + type FreeformComboboxOption, | |
| 13 | +} from "@executor-js/react/components/combobox"; | |
| 14 | +import { Input } from "@executor-js/react/components/input"; | |
| 15 | +import { SourceFavicon } from "@executor-js/react/components/source-favicon"; | |
| 16 | +import { | |
| 17 | + SourceIdentityFieldRows, | |
| 18 | + type SourceIdentity, | |
| 19 | +} from "@executor-js/react/plugins/source-identity"; | |
| 20 | + | |
| 21 | +export function OpenApiSourceDetailsFields(props: { | |
| 22 | + readonly title: string; | |
| 23 | + readonly description?: string; | |
| 24 | + readonly identity: SourceIdentity; | |
| 25 | + readonly baseUrl: string; | |
| 26 | + readonly onBaseUrlChange: (value: string) => void; | |
| 27 | + readonly baseUrlOptions?: readonly FreeformComboboxOption[]; | |
| 28 | + readonly specUrl?: string; | |
| 29 | + readonly onSpecUrlChange?: (value: string) => void; | |
| 30 | + readonly faviconUrl?: string; | |
| 31 | + readonly namespaceReadOnly?: boolean; | |
| 32 | + readonly specUrlDisabled?: boolean; | |
| 33 | + readonly saveState?: "idle" | "saving" | "saved"; | |
| 34 | + readonly baseUrlMissingMessage?: string; | |
| 35 | + readonly footer?: string; | |
| 36 | +}) { | |
| 37 | + const baseUrlOptions = props.baseUrlOptions ?? []; | |
| 38 | + | |
| 39 | + return ( | |
| 40 | + <CardStack> | |
| 41 | + <CardStackContent className="border-t-0"> | |
| 42 | + <CardStackEntry> | |
| 43 | + {props.faviconUrl && <SourceFavicon url={props.faviconUrl} size={16} />} | |
| 44 | + <CardStackEntryContent> | |
| 45 | + <CardStackEntryTitle>{props.title}</CardStackEntryTitle> | |
| 46 | + {props.description && ( | |
| 47 | + <CardStackEntryDescription>{props.description}</CardStackEntryDescription> | |
| 48 | + )} | |
| 49 | + </CardStackEntryContent> | |
| 50 | + {props.saveState && props.saveState !== "idle" && ( | |
| 51 | + <span className="text-xs text-muted-foreground"> | |
| 52 | + {props.saveState === "saving" ? "Saving…" : "Saved"} | |
| 53 | + </span> | |
| 54 | + )} | |
| 55 | + </CardStackEntry> | |
| 56 | + <SourceIdentityFieldRows | |
| 57 | + identity={props.identity} | |
| 58 | + namespaceReadOnly={props.namespaceReadOnly} | |
| 59 | + /> | |
| 60 | + <div className="grid grid-cols-1 md:grid-cols-2"> | |
| 61 | + <CardStackEntryField label="Base URL"> | |
| 62 | + {baseUrlOptions.length > 0 ? ( | |
| 63 | + <FreeformCombobox | |
| 64 | + value={props.baseUrl} | |
| 65 | + onValueChange={props.onBaseUrlChange} | |
| 66 | + options={baseUrlOptions} | |
| 67 | + placeholder="https://api.example.com" | |
| 68 | + className="w-full" | |
| 69 | + inputClassName="font-mono text-sm" | |
| 70 | + /> | |
| 71 | + ) : ( | |
| 72 | + <Input | |
| 73 | + value={props.baseUrl} | |
| 74 | + onChange={(e) => props.onBaseUrlChange((e.target as HTMLInputElement).value)} | |
| 75 | + placeholder="https://api.example.com" | |
| 76 | + className="font-mono text-sm" | |
| 77 | + /> | |
| 78 | + )} | |
| 79 | + | |
| 80 | + {props.baseUrlMissingMessage && !props.baseUrl && ( | |
| 81 | + <p className="text-[11px] text-amber-600 dark:text-amber-400"> | |
| 82 | + {props.baseUrlMissingMessage} | |
| 83 | + </p> | |
| 84 | + )} | |
| 85 | + </CardStackEntryField> | |
| 86 | + {props.specUrl !== undefined && props.onSpecUrlChange && ( | |
| 87 | + <CardStackEntryField label="Spec URL"> | |
| 88 | + <Input | |
| 89 | + value={props.specUrl} | |
| 90 | + onChange={(e) => props.onSpecUrlChange?.((e.target as HTMLInputElement).value)} | |
| 91 | + placeholder="https://api.example.com/openapi.json" | |
| 92 | + className="font-mono text-sm" | |
| 93 | + disabled={props.specUrlDisabled} | |
| 94 | + /> | |
| 95 | + </CardStackEntryField> | |
| 96 | + )} | |
| 97 | + </div> | |
| 98 | + {props.footer && ( | |
| 99 | + <CardStackEntry> | |
| 100 | + <CardStackEntryContent> | |
| 101 | + <CardStackEntryTitle>{props.footer}</CardStackEntryTitle> | |
| 102 | + </CardStackEntryContent> | |
| 103 | + </CardStackEntry> | |
| 104 | + )} | |
| 105 | + </CardStackContent> | |
| 106 | + </CardStack> | |
| 107 | + ); | |
| 108 | +} | |
| @@ -3,39 +3,23 @@ import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; · 2 unmodified lines | ||
| 3 | 3 | |
| 4 | 4 | import { connectionsAtom, sourceAtom } from "@executor-js/react/api/atoms"; |
| 5 | 5 | import { Badge } from "@executor-js/react/components/badge"; |
| 6 | −import { Button } from "@executor-js/react/components/button"; | |
| 7 | 6 | import { useScope, useScopeStack, useUserScope } from "@executor-js/react/api/scope-context"; |
| 8 | 7 | import { ScopeId } from "@executor-js/sdk/core"; |
| 8 | +import { | |
| 9 | + SourceCredentialNotice, | |
| 10 | + SourceCredentialStatusBadge, | |
| 11 | + missingSourceCredentialLabels, | |
| 12 | + type SourceCredentialSlot, | |
| 13 | +} from "@executor-js/react/plugins/source-credential-status"; | |
| 9 | 14 | |
| 10 | 15 | import { openApiSourceAtom, openApiSourceBindingsAtom } from "./atoms"; |
| 11 | −import { effectiveBindingForScope, missingCredentialLabels } from "../sdk/credential-status"; | |
| 12 | − | |
| 13 | −function ConnectedBadge() { | |
| 14 | − return ( | |
| 15 | − <Badge | |
| 16 | − variant="outline" | |
| 17 | − className="border-green-500/30 bg-green-500/5 text-[10px] text-green-700 dark:text-green-400" | |
| 18 | − > | |
| 19 | − Connected | |
| 20 | − </Badge> | |
| 21 | − ); | |
| 22 | −} | |
| 16 | +import { effectiveBindingForScope } from "../sdk/credential-status"; | |
| 17 | +import { oauth2ClientSecretSlot, type StoredSourceSchemaType } from "../sdk/store"; | |
| 23 | 18 | |
| 24 | 19 | function OAuthBadge() { |
| 25 | 20 | return <Badge variant="secondary">OAuth</Badge>; |
| 26 | 21 | } |
| 27 | 22 | |
| 28 | −function NeedsCredentialsBadge() { | |
| 29 | − return ( | |
| 30 | − <Badge | |
| 31 | − variant="outline" | |
| 32 | − className="border-amber-500/40 bg-amber-500/10 text-[10px] text-amber-700 dark:text-amber-300" | |
| 33 | − > | |
| 34 | − Needs credentials | |
| 35 | − </Badge> | |
| 36 | − ); | |
| 37 | −} | |
| 38 | − | |
| 39 | 23 | function CheckingCredentialsBadge() { |
| 40 | 24 | return ( |
| 41 | 25 | <Badge |
| @@ -47,6 +31,36 @@ function CheckingCredentialsBadge() { · 5 unmodified lines | ||
| 47 | 31 | ); |
| 48 | 32 | } |
| 49 | 33 | |
| 34 | +const effectiveClientSecretSlot = (oauth2: { | |
| 35 | + readonly securitySchemeName: string; | |
| 36 | + readonly clientSecretSlot: string | null; | |
| 37 | +}): string => oauth2.clientSecretSlot ?? oauth2ClientSecretSlot(oauth2.securitySchemeName); | |
| 38 | + | |
| 39 | +const sourceCredentialSlots = (source: StoredSourceSchemaType): readonly SourceCredentialSlot[] => { | |
| 40 | + const slots: SourceCredentialSlot[] = []; | |
| 41 | + for (const [name, value] of Object.entries(source.config.headers ?? {})) { | |
| 42 | + if (typeof value !== "string") slots.push({ kind: "secret", slot: value.slot, label: name }); | |
| 43 | + } | |
| 44 | + for (const [name, value] of Object.entries(source.config.queryParams ?? {})) { | |
| 45 | + if (typeof value !== "string") slots.push({ kind: "secret", slot: value.slot, label: name }); | |
| 46 | + } | |
| 47 | + const oauth2 = source.config.oauth2; | |
| 48 | + if (oauth2) { | |
| 49 | + slots.push({ kind: "secret", slot: oauth2.clientIdSlot, label: "Client ID" }); | |
| 50 | + slots.push({ | |
| 51 | + kind: "secret", | |
| 52 | + slot: effectiveClientSecretSlot(oauth2), | |
| 53 | + label: "Client Secret", | |
| 54 | + }); | |
| 55 | + slots.push({ | |
| 56 | + kind: "connection", | |
| 57 | + slot: oauth2.connectionSlot, | |
| 58 | + label: oauth2.flow === "clientCredentials" ? "OAuth client connection" : "OAuth sign-in", | |
| 59 | + }); | |
| 60 | + } | |
| 61 | + return slots; | |
| 62 | +}; | |
| 63 | + | |
| 50 | 64 | // The entry row already renders name + id + kind, so this summary |
| 51 | 65 | // component only contributes extras — specifically, an OAuth status |
| 52 | 66 | // badge when the source has OAuth2 configured. Non-OAuth sources |
| @@ -89,34 +103,19 @@ export default function OpenApiSourceSummary(props: { · 36 unmodified lines | ||
| 89 | 103 | const liveConnectionIds = new Set(connections.map((connection) => connection.id)); |
| 90 | 104 | const scopeRanks = new Map(scopeStack.map((scope, index) => [scope.id, index] as const)); |
| 91 | 105 | const credentialTargetScope = userScope; |
| 92 | − const missing = missingCredentialLabels(source, bindings, credentialTargetScope, scopeRanks, { | |
| 106 | + const missing = missingSourceCredentialLabels({ | |
| 107 | + slots: sourceCredentialSlots(source), | |
| 108 | + bindings, | |
| 109 | + targetScope: credentialTargetScope, | |
| 110 | + scopeRanks, | |
| 93 | 111 | liveConnectionIds, |
| 94 | 112 | }); |
| 95 | 113 | |
| 96 | 114 | if (props.variant === "panel") { |
| 97 | − if (missing.length === 0) return null; | |
| 98 | − return ( | |
| 99 | − <div className="shrink-0 border-b border-amber-500/20 bg-amber-500/[0.06] px-4 py-3"> | |
| 100 | − <div className="mx-auto flex max-w-4xl items-center justify-between gap-4"> | |
| 101 | − <div className="min-w-0"> | |
| 102 | − <div className="text-sm font-medium text-foreground"> | |
| 103 | − This source needs your credentials before tools can run. | |
| 104 | − </div> | |
| 105 | − <div className="mt-0.5 truncate text-xs text-muted-foreground"> | |
| 106 | − Missing: {missing.join(", ")} | |
| 107 | − </div> | |
| 108 | − </div> | |
| 109 | − {props.onAction && ( | |
| 110 | − <Button size="sm" onClick={props.onAction}> | |
| 111 | − Add credentials | |
| 112 | − </Button> | |
| 113 | − )} | |
| 114 | − </div> | |
| 115 | − </div> | |
| 116 | − ); | |
| 115 | + return <SourceCredentialNotice missing={missing} onAction={props.onAction} />; | |
| 117 | 116 | } |
| 118 | 117 | |
| 119 | − if (missing.length > 0) return <NeedsCredentialsBadge />; | |
| 118 | + if (missing.length > 0) return <SourceCredentialStatusBadge missing={missing} />; | |
| 120 | 119 | |
| 121 | 120 | if (!oauth2) return null; |
| 122 | 121 | const connectionBinding = effectiveBindingForScope( |
| @@ -131,7 +130,7 @@ export default function OpenApiSourceSummary(props: { · 8 unmodified lines | ||
| 131 | 130 | : null; |
| 132 | 131 | |
| 133 | 132 | if (connectionId && connections.some((connection) => connection.id === connectionId)) { |
| 134 | − return <ConnectedBadge />; | |
| 133 | + return <SourceCredentialStatusBadge missing={[]} />; | |
| 135 | 134 | } |
| 136 | 135 | |
| 137 | 136 | return <OAuthBadge />; |
| @@ -166,12 +166,6 @@ export function SourceDetailPage(props: { namespace: string }) { · 165 unmodified lines | ||
| 166 | 166 | </div> |
| 167 | 167 | |
| 168 | 168 | <div className="flex shrink-0 items-center gap-2"> |
| 169 | − {editPlugin?.signIn && !editing && !confirmDelete && ( | |
| 170 | − <Suspense fallback={null}> | |
| 171 | − <editPlugin.signIn sourceId={namespace} /> | |
| 172 | − </Suspense> | |
| 173 | − )} | |
| 174 | − | |
| 175 | 169 | {canEdit && editPlugin && !editing && !confirmDelete && ( |
| 176 | 170 | <Button variant="outline" size="sm" onClick={() => setEditing(true)}> |
| 177 | 171 | Edit |
| @@ -170,12 +170,15 @@ export function CredentialScopeDropdown(props: { · 169 unmodified lines | ||
| 170 | 170 | readonly onChange: (scope: ScopeId) => void; |
| 171 | 171 | readonly label?: string; |
| 172 | 172 | readonly help?: ReactNode; |
| 173 | + readonly className?: string; | |
| 174 | + readonly triggerClassName?: string; | |
| 175 | + readonly size?: "sm" | "default"; | |
| 173 | 176 | }) { |
| 174 | 177 | const label = props.label ?? "Used by"; |
| 175 | 178 | if (props.options.length <= 1) return null; |
| 176 | 179 | |
| 177 | 180 | return ( |
| 178 | − <div className="space-y-1.5"> | |
| 181 | + <div className={props.className ?? "space-y-1.5"}> | |
| 179 | 182 | <div className="flex items-center gap-1.5"> |
| 180 | 183 | <FieldLabel className="text-[11px]">{label}</FieldLabel> |
| 181 | 184 | <HelpTooltip label={label}> |
| @@ -186,7 +189,7 @@ export function CredentialScopeDropdown(props: { · 4 unmodified lines | ||
| 186 | 189 | value={String(props.value)} |
| 187 | 190 | onValueChange={(value) => props.onChange(ScopeId.make(value))} |
| 188 | 191 | > |
| 189 | − <SelectTrigger className="w-full"> | |
| 192 | + <SelectTrigger className={props.triggerClassName ?? "w-full"} size={props.size}> | |
| 190 | 193 | <SelectValue placeholder={label} /> |
| 191 | 194 | </SelectTrigger> |
| 192 | 195 | <SelectContent> |
| @@ -230,7 +233,7 @@ export function CredentialUsageRow(props: { · 37 unmodified lines | ||
| 230 | 233 | } |
| 231 | 234 | |
| 232 | 235 | return ( |
| 233 | − <div className="grid gap-2 md:grid-cols-2"> | |
| 236 | + <div className="grid items-stretch gap-2 md:grid-cols-2"> | |
| 234 | 237 | <div className="min-w-0 space-y-2.5">{props.children}</div> |
| 235 | 238 | <CredentialScopeDropdown |
| 236 | 239 | value={props.value} |
| @@ -238,6 +241,8 @@ export function CredentialUsageRow(props: { · 1 unmodified line | ||
| 238 | 241 | onChange={props.onChange} |
| 239 | 242 | label={props.label} |
| 240 | 243 | help={props.help} |
| 244 | + className="flex h-full flex-col space-y-1.5" | |
| 245 | + triggerClassName="w-full min-h-9 flex-1 data-[size=default]:h-full" | |
| 241 | 246 | /> |
| 242 | 247 | </div> |
| 243 | 248 | ); |
| @@ -0,0 +1,78 @@ | ||
| 1 | +import type { ConnectionId, CredentialBindingValue, ScopeId } from "@executor-js/sdk"; | |
| 2 | + | |
| 3 | +export type SourceCredentialSlot = | |
| 4 | + | { | |
| 5 | + readonly kind: "secret"; | |
| 6 | + readonly slot: string; | |
| 7 | + readonly label: string; | |
| 8 | + readonly optional?: boolean; | |
| 9 | + } | |
| 10 | + | { | |
| 11 | + readonly kind: "connection"; | |
| 12 | + readonly slot: string; | |
| 13 | + readonly label: string; | |
| 14 | + readonly optional?: boolean; | |
| 15 | + }; | |
| 16 | + | |
| 17 | +export type SourceCredentialBindingRef = { | |
| 18 | + readonly slot: string; | |
| 19 | + readonly scopeId: ScopeId; | |
| 20 | + readonly value: CredentialBindingValue; | |
| 21 | +}; | |
| 22 | + | |
| 23 | +const scopeRank = (ranks: ReadonlyMap<string, number>, scopeId: ScopeId): number => | |
| 24 | + ranks.get(scopeId) ?? Number.MAX_SAFE_INTEGER; | |
| 25 | + | |
| 26 | +export const effectiveSourceCredentialBinding = ( | |
| 27 | + rows: readonly SourceCredentialBindingRef[], | |
| 28 | + slot: string, | |
| 29 | + targetScope: ScopeId, | |
| 30 | + ranks: ReadonlyMap<string, number>, | |
| 31 | +): SourceCredentialBindingRef | null => | |
| 32 | + rows | |
| 33 | + .filter( | |
| 34 | + (row) => row.slot === slot && scopeRank(ranks, row.scopeId) >= scopeRank(ranks, targetScope), | |
| 35 | + ) | |
| 36 | + .sort((a, b) => scopeRank(ranks, a.scopeId) - scopeRank(ranks, b.scopeId))[0] ?? null; | |
| 37 | + | |
| 38 | +const liveConnectionSet = ( | |
| 39 | + values?: ReadonlySet<string> | readonly ConnectionId[], | |
| 40 | +): ReadonlySet<string> | undefined => (values ? new Set(Array.from(values, String)) : undefined); | |
| 41 | + | |
| 42 | +export const missingSourceCredentialLabels = (input: { | |
| 43 | + readonly slots: readonly SourceCredentialSlot[]; | |
| 44 | + readonly bindings: readonly SourceCredentialBindingRef[]; | |
| 45 | + readonly targetScope: ScopeId; | |
| 46 | + readonly scopeRanks: ReadonlyMap<string, number>; | |
| 47 | + readonly liveConnectionIds?: ReadonlySet<string> | readonly ConnectionId[]; | |
| 48 | +}): string[] => { | |
| 49 | + const liveConnections = liveConnectionSet(input.liveConnectionIds); | |
| 50 | + const missing: string[] = []; | |
| 51 | + | |
| 52 | + for (const slot of input.slots) { | |
| 53 | + if (slot.optional) continue; | |
| 54 | + const binding = effectiveSourceCredentialBinding( | |
| 55 | + input.bindings, | |
| 56 | + slot.slot, | |
| 57 | + input.targetScope, | |
| 58 | + input.scopeRanks, | |
| 59 | + ); | |
| 60 | + | |
| 61 | + if (slot.kind === "secret" && binding?.value.kind !== "secret") { | |
| 62 | + missing.push(slot.label); | |
| 63 | + continue; | |
| 64 | + } | |
| 65 | + | |
| 66 | + if (slot.kind === "connection") { | |
| 67 | + if (binding?.value.kind !== "connection") { | |
| 68 | + missing.push(slot.label); | |
| 69 | + continue; | |
| 70 | + } | |
| 71 | + if (liveConnections && !liveConnections.has(binding.value.connectionId)) { | |
| 72 | + missing.push(slot.label); | |
| 73 | + } | |
| 74 | + } | |
| 75 | + } | |
| 76 | + | |
| 77 | + return missing; | |
| 78 | +}; | |
| @@ -0,0 +1,88 @@ | ||
| 1 | +import { describe, expect, it } from "@effect/vitest"; | |
| 2 | +import { ConnectionId, ScopeId, SecretId } from "@executor-js/sdk"; | |
| 3 | + | |
| 4 | +import { | |
| 5 | + effectiveSourceCredentialBinding, | |
| 6 | + missingSourceCredentialLabels, | |
| 7 | + type SourceCredentialBindingRef, | |
| 8 | + type SourceCredentialSlot, | |
| 9 | +} from "./source-credential-status-core"; | |
| 10 | + | |
| 11 | +const userScope = ScopeId.make("user"); | |
| 12 | +const orgScope = ScopeId.make("org"); | |
| 13 | +const scopeRanks = new Map([ | |
| 14 | + [userScope, 0], | |
| 15 | + [orgScope, 1], | |
| 16 | +]); | |
| 17 | + | |
| 18 | +const slots: readonly SourceCredentialSlot[] = [ | |
| 19 | + { kind: "secret", slot: "header:authorization", label: "Authorization" }, | |
| 20 | + { kind: "connection", slot: "auth:oauth2:connection", label: "OAuth sign-in" }, | |
| 21 | +]; | |
| 22 | + | |
| 23 | +const bindings = (scopeId: ScopeId): readonly SourceCredentialBindingRef[] => [ | |
| 24 | + { | |
| 25 | + slot: "header:authorization", | |
| 26 | + scopeId, | |
| 27 | + value: { | |
| 28 | + kind: "secret", | |
| 29 | + secretId: SecretId.make(`${scopeId}-token`), | |
| 30 | + }, | |
| 31 | + }, | |
| 32 | + { | |
| 33 | + slot: "auth:oauth2:connection", | |
| 34 | + scopeId, | |
| 35 | + value: { | |
| 36 | + kind: "connection", | |
| 37 | + connectionId: ConnectionId.make(`${scopeId}-connection`), | |
| 38 | + }, | |
| 39 | + }, | |
| 40 | +]; | |
| 41 | + | |
| 42 | +describe("source credential status", () => { | |
| 43 | + it("treats inherited source-scope credentials as satisfying a user target", () => { | |
| 44 | + expect( | |
| 45 | + missingSourceCredentialLabels({ | |
| 46 | + slots, | |
| 47 | + bindings: bindings(orgScope), | |
| 48 | + targetScope: userScope, | |
| 49 | + scopeRanks, | |
| 50 | + liveConnectionIds: [ConnectionId.make("org-connection")], | |
| 51 | + }), | |
| 52 | + ).toEqual([]); | |
| 53 | + }); | |
| 54 | + | |
| 55 | + it("does not let inner-scope credentials satisfy an outer target", () => { | |
| 56 | + expect( | |
| 57 | + missingSourceCredentialLabels({ | |
| 58 | + slots, | |
| 59 | + bindings: bindings(userScope), | |
| 60 | + targetScope: orgScope, | |
| 61 | + scopeRanks, | |
| 62 | + }), | |
| 63 | + ).toEqual(["Authorization", "OAuth sign-in"]); | |
| 64 | + }); | |
| 65 | + | |
| 66 | + it("treats stale connection bindings as missing", () => { | |
| 67 | + expect( | |
| 68 | + missingSourceCredentialLabels({ | |
| 69 | + slots, | |
| 70 | + bindings: bindings(userScope), | |
| 71 | + targetScope: userScope, | |
| 72 | + scopeRanks, | |
| 73 | + liveConnectionIds: [], | |
| 74 | + }), | |
| 75 | + ).toEqual(["OAuth sign-in"]); | |
| 76 | + }); | |
| 77 | + | |
| 78 | + it("prefers the inner exact binding over inherited credentials", () => { | |
| 79 | + const binding = effectiveSourceCredentialBinding( | |
| 80 | + [...bindings(orgScope), ...bindings(userScope)], | |
| 81 | + "header:authorization", | |
| 82 | + userScope, | |
| 83 | + scopeRanks, | |
| 84 | + ); | |
| 85 | + | |
| 86 | + expect(binding?.scopeId).toEqual(userScope); | |
| 87 | + }); | |
| 88 | +}); | |
| @@ -0,0 +1,55 @@ | ||
| 1 | +import { Badge } from "../components/badge"; | |
| 2 | +import { Button } from "../components/button"; | |
| 3 | +export { | |
| 4 | + effectiveSourceCredentialBinding, | |
| 5 | + missingSourceCredentialLabels, | |
| 6 | + type SourceCredentialBindingRef, | |
| 7 | + type SourceCredentialSlot, | |
| 8 | +} from "./source-credential-status-core"; | |
| 9 | + | |
| 10 | +export function SourceCredentialStatusBadge(props: { readonly missing: readonly string[] }) { | |
| 11 | + if (props.missing.length === 0) { | |
| 12 | + return ( | |
| 13 | + <Badge | |
| 14 | + variant="outline" | |
| 15 | + className="border-green-500/30 bg-green-500/5 text-[10px] text-green-700 dark:text-green-400" | |
| 16 | + > | |
| 17 | + Credentials ready | |
| 18 | + </Badge> | |
| 19 | + ); | |
| 20 | + } | |
| 21 | + | |
| 22 | + return ( | |
| 23 | + <Badge | |
| 24 | + variant="outline" | |
| 25 | + className="border-amber-500/40 bg-amber-500/10 text-[10px] text-amber-700 dark:text-amber-300" | |
| 26 | + > | |
| 27 | + Credentials needed | |
| 28 | + </Badge> | |
| 29 | + ); | |
| 30 | +} | |
| 31 | + | |
| 32 | +export function SourceCredentialNotice(props: { | |
| 33 | + readonly missing: readonly string[]; | |
| 34 | + readonly onAction?: () => void; | |
| 35 | +}) { | |
| 36 | + if (props.missing.length === 0) return null; | |
| 37 | + | |
| 38 | + return ( | |
| 39 | + <div className="shrink-0 border-b border-border bg-muted/30 px-4 py-3"> | |
| 40 | + <div className="mx-auto flex max-w-4xl items-center justify-between gap-4"> | |
| 41 | + <div className="min-w-0"> | |
| 42 | + <div className="text-sm font-medium text-foreground">Credentials need attention</div> | |
| 43 | + <div className="mt-0.5 truncate text-xs text-muted-foreground"> | |
| 44 | + Missing {props.missing.join(", ")} | |
| 45 | + </div> | |
| 46 | + </div> | |
| 47 | + {props.onAction && ( | |
| 48 | + <Button size="sm" variant="outline" onClick={props.onAction}> | |
| 49 | + Configure | |
| 50 | + </Button> | |
| 51 | + )} | |
| 52 | + </div> | |
| 53 | + </div> | |
| 54 | + ); | |
| 55 | +} | |
| @@ -0,0 +1,68 @@ | ||
| 1 | +import type { ConnectionId, ScopeId, SecretBackedValue } from "@executor-js/sdk"; | |
| 2 | + | |
| 3 | +import { | |
| 4 | + CredentialControlField, | |
| 5 | + CredentialUsageRow, | |
| 6 | + type CredentialTargetScopeOption, | |
| 7 | +} from "./credential-target-scope"; | |
| 8 | +import { SourceOAuthSignInButton } from "./oauth-sign-in"; | |
| 9 | + | |
| 10 | +export function SourceOAuthConnectionControl(props: { | |
| 11 | + readonly popupName: string; | |
| 12 | + readonly pluginId: string; | |
| 13 | + readonly namespace: string; | |
| 14 | + readonly fallbackNamespace: string; | |
| 15 | + readonly endpoint: string; | |
| 16 | + readonly tokenScope: ScopeId; | |
| 17 | + readonly onTokenScopeChange: (scope: ScopeId) => void; | |
| 18 | + readonly credentialScopeOptions: readonly CredentialTargetScopeOption[]; | |
| 19 | + readonly connectionId: string | null; | |
| 20 | + readonly sourceLabel: string; | |
| 21 | + readonly headers?: Record<string, SecretBackedValue>; | |
| 22 | + readonly queryParams?: Record<string, SecretBackedValue>; | |
| 23 | + readonly isConnected: boolean; | |
| 24 | + readonly onConnected: (connectionId: ConnectionId) => void | Promise<void>; | |
| 25 | + readonly disabled?: boolean; | |
| 26 | + readonly reconnectingLabel?: string; | |
| 27 | + readonly signingInLabel?: string; | |
| 28 | +}) { | |
| 29 | + return ( | |
| 30 | + <CredentialUsageRow | |
| 31 | + value={props.tokenScope} | |
| 32 | + options={props.credentialScopeOptions} | |
| 33 | + onChange={props.onTokenScopeChange} | |
| 34 | + label="Connection saved to" | |
| 35 | + help="Choose who can use the OAuth connection." | |
| 36 | + > | |
| 37 | + <CredentialControlField label="OAuth connection" help="Start the provider OAuth flow."> | |
| 38 | + <div className="flex min-h-9 items-center gap-2 rounded-md border border-border bg-muted/30 px-3 py-2"> | |
| 39 | + {props.isConnected ? ( | |
| 40 | + <span className="text-xs font-medium text-emerald-600 dark:text-emerald-400"> | |
| 41 | + Connected | |
| 42 | + </span> | |
| 43 | + ) : ( | |
| 44 | + <span className="text-xs text-muted-foreground">Not connected</span> | |
| 45 | + )} | |
| 46 | + <div className="ml-auto"> | |
| 47 | + <SourceOAuthSignInButton | |
| 48 | + popupName={props.popupName} | |
| 49 | + pluginId={props.pluginId} | |
| 50 | + namespace={props.namespace} | |
| 51 | + fallbackNamespace={props.fallbackNamespace} | |
| 52 | + endpoint={props.endpoint} | |
| 53 | + tokenScope={props.tokenScope} | |
| 54 | + connectionId={props.connectionId} | |
| 55 | + sourceLabel={props.sourceLabel} | |
| 56 | + headers={props.headers} | |
| 57 | + queryParams={props.queryParams} | |
| 58 | + isConnected={props.isConnected} | |
| 59 | + onConnected={props.onConnected} | |
| 60 | + reconnectingLabel={props.reconnectingLabel} | |
| 61 | + signingInLabel={props.signingInLabel} | |
| 62 | + /> | |
| 63 | + </div> | |
| 64 | + </div> | |
| 65 | + </CredentialControlField> | |
| 66 | + </CredentialUsageRow> | |
| 67 | + ); | |
| 68 | +} | |