Refactor source credential edit surfaces
Unified · Split
| @@ -122,9 +122,6 @@ export interface SourcePlugin { · 121 unmodified lines | |||
| 122 | readonly variant?: "badge" | "panel"; | 122 | readonly variant?: "badge" | "panel"; |
| 123 | readonly onAction?: () => void; | 123 | readonly onAction?: () => void; |
| 124 | }>; | 124 | }>; |
| 125 | readonly signIn?: ComponentType<{ | ||
| 126 | readonly sourceId: string; | ||
| 127 | }>; | ||
| 128 | readonly presets?: readonly SourcePreset[]; | 125 | readonly presets?: readonly SourcePreset[]; |
| 129 | /** Trigger early download of the plugin's lazy component chunks (add/edit/etc.). | 126 | /** Trigger early download of the plugin's lazy component chunks (add/edit/etc.). |
| 130 | * Call from the host on intent (hover/focus) so the chunks land before the | 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 | import { Button } from "@executor-js/react/components/button"; | 5 | import { Button } from "@executor-js/react/components/button"; |
| 6 | 6 | ||
| 7 | import { googleDiscoverySourceAtom } from "./atoms"; | 7 | import { googleDiscoverySourceAtom } from "./atoms"; |
| 8 | import GoogleDiscoverySignInButton from "./GoogleDiscoverySignInButton"; | ||
| 8 | 9 | ||
| 9 | export default function EditGoogleDiscoverySource({ | 10 | export default function EditGoogleDiscoverySource({ |
| 10 | sourceId, | 11 | sourceId, |
| @@ -63,9 +64,12 @@ export default function EditGoogleDiscoverySource({ · 52 unmodified lines | |||
| 63 | <p className="text-xs uppercase tracking-wider text-muted-foreground mb-1"> | 64 | <p className="text-xs uppercase tracking-wider text-muted-foreground mb-1"> |
| 64 | Authentication | 65 | Authentication |
| 65 | </p> | 66 | </p> |
| 66 | <p className="text-sm font-medium text-foreground capitalize"> | 67 | <div className="flex min-h-9 items-center justify-between gap-3"> |
| 67 | {authKind === "oauth2" ? "OAuth 2.0" : authKind} | 68 | <p className="text-sm font-medium text-foreground capitalize"> |
| 68 | </p> | 69 | {authKind === "oauth2" ? "OAuth 2.0" : authKind} |
| 70 | </p> | ||
| 71 | {authKind === "oauth2" && <GoogleDiscoverySignInButton sourceId={sourceId} />} | ||
| 72 | </div> | ||
| 69 | </div> | 73 | </div> |
| 70 | </div> | 74 | </div> |
| 71 | )} | 75 | )} |
| @@ -5,7 +5,6 @@ import { googleDiscoveryPresets } from "../sdk/presets"; · 4 unmodified lines | |||
| 5 | const importAdd = () => import("./AddGoogleDiscoverySource"); | 5 | const importAdd = () => import("./AddGoogleDiscoverySource"); |
| 6 | const importEdit = () => import("./EditGoogleDiscoverySource"); | 6 | const importEdit = () => import("./EditGoogleDiscoverySource"); |
| 7 | const importSummary = () => import("./GoogleDiscoverySourceSummary"); | 7 | const importSummary = () => import("./GoogleDiscoverySourceSummary"); |
| 8 | const importSignIn = () => import("./GoogleDiscoverySignInButton"); | ||
| 9 | 8 | ||
| 10 | export const googleDiscoverySourcePlugin: SourcePlugin = { | 9 | export const googleDiscoverySourcePlugin: SourcePlugin = { |
| 11 | key: "googleDiscovery", | 10 | key: "googleDiscovery", |
| @@ -13,12 +12,10 @@ export const googleDiscoverySourcePlugin: SourcePlugin = { · 1 unmodified line | |||
| 13 | add: lazy(importAdd), | 12 | add: lazy(importAdd), |
| 14 | edit: lazy(importEdit), | 13 | edit: lazy(importEdit), |
| 15 | summary: lazy(importSummary), | 14 | summary: lazy(importSummary), |
| 16 | signIn: lazy(importSignIn), | ||
| 17 | presets: googleDiscoveryPresets, | 15 | presets: googleDiscoveryPresets, |
| 18 | preload: () => { | 16 | preload: () => { |
| 19 | void importAdd(); | 17 | void importAdd(); |
| 20 | void importEdit(); | 18 | void importEdit(); |
| 21 | void importSummary(); | 19 | void importSummary(); |
| 22 | void importSignIn(); | ||
| 23 | }, | 20 | }, |
| 24 | }; | 21 | }; |
| @@ -16,7 +16,6 @@ import { · 15 unmodified lines | |||
| 16 | import { | 16 | import { |
| 17 | sourceDisplayNameFromUrl, | 17 | sourceDisplayNameFromUrl, |
| 18 | slugifyNamespace, | 18 | slugifyNamespace, |
| 19 | SourceIdentityFieldRows, | ||
| 20 | useSourceIdentity, | 19 | useSourceIdentity, |
| 21 | } from "@executor-js/react/plugins/source-identity"; | 20 | } from "@executor-js/react/plugins/source-identity"; |
| 22 | import { | 21 | import { |
| @@ -33,16 +32,11 @@ import { · 10 unmodified lines | |||
| 33 | import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets"; | 32 | import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets"; |
| 34 | import { Button } from "@executor-js/react/components/button"; | 33 | import { Button } from "@executor-js/react/components/button"; |
| 35 | import { FilterTabs } from "@executor-js/react/components/filter-tabs"; | 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 | import { FloatActions } from "@executor-js/react/components/float-actions"; | 35 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 42 | import { Input } from "@executor-js/react/components/input"; | ||
| 43 | import { Spinner } from "@executor-js/react/components/spinner"; | 36 | import { Spinner } from "@executor-js/react/components/spinner"; |
| 44 | import { addGraphqlSourceOptimistic } from "./atoms"; | 37 | import { addGraphqlSourceOptimistic } from "./atoms"; |
| 45 | import { initialGraphqlCredentials } from "./defaults"; | 38 | import { initialGraphqlCredentials } from "./defaults"; |
| 39 | import { GraphqlSourceFields } from "./GraphqlSourceFields"; | ||
| 46 | import type { GraphqlCredentialInput } from "../sdk/types"; | 40 | import type { GraphqlCredentialInput } from "../sdk/types"; |
| 47 | 41 | ||
| 48 | const ErrorMessage = Schema.Struct({ message: Schema.String }); | 42 | const ErrorMessage = Schema.Struct({ message: Schema.String }); |
| @@ -181,22 +175,7 @@ export default function AddGraphqlSource(props: { · 132 unmodified lines | |||
| 181 | <div className="flex flex-1 flex-col gap-6"> | 175 | <div className="flex flex-1 flex-col gap-6"> |
| 182 | <h1 className="text-xl font-semibold text-foreground">Add GraphQL Source</h1> | 176 | <h1 className="text-xl font-semibold text-foreground">Add GraphQL Source</h1> |
| 183 | 177 | ||
| 184 | <CardStack> | 178 | <GraphqlSourceFields endpoint={endpoint} onEndpointChange={setEndpoint} identity={identity} /> |
| 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> | ||
| 200 | 179 | ||
| 201 | <HttpCredentialsEditor | 180 | <HttpCredentialsEditor |
| 202 | credentials={credentials} | 181 | credentials={credentials} |
| @@ -2,40 +2,39 @@ import { useState } from "react"; · 1 unmodified line | |||
| 2 | import { useAtomValue, useAtomSet } from "@effect/atom-react"; | 2 | import { useAtomValue, useAtomSet } from "@effect/atom-react"; |
| 3 | import * as Exit from "effect/Exit"; | 3 | import * as Exit from "effect/Exit"; |
| 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 4 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 5 | import { graphqlSourceAtom, graphqlSourceBindingsAtom, updateGraphqlSource } from "./atoms"; | 5 | import { |
| 6 | import { useScope } from "@executor-js/react/api/scope-context"; | 6 | graphqlSourceAtom, |
| 7 | import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; | 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 | import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets"; | 14 | import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets"; |
| 9 | import { | 15 | import { |
| 10 | HttpCredentialsEditor, | 16 | HttpCredentialsEditor, |
| 17 | serializeHttpCredentials, | ||
| 11 | serializeScopedHttpCredentials, | 18 | serializeScopedHttpCredentials, |
| 12 | type HttpCredentialsState, | 19 | type HttpCredentialsState, |
| 13 | } from "@executor-js/react/plugins/http-credentials"; | 20 | } from "@executor-js/react/plugins/http-credentials"; |
| 14 | import { | 21 | import { |
| 22 | effectiveCredentialBindingForScope, | ||
| 15 | httpCredentialsFromConfiguredCredentialBindings, | 23 | httpCredentialsFromConfiguredCredentialBindings, |
| 16 | initialCredentialTargetScope, | 24 | initialCredentialTargetScope, |
| 17 | } from "@executor-js/react/plugins/credential-bindings"; | 25 | } from "@executor-js/react/plugins/credential-bindings"; |
| 18 | import { | 26 | import { slugifyNamespace, useSourceIdentity } from "@executor-js/react/plugins/source-identity"; |
| 19 | SourceIdentityFields, | 27 | import { useCredentialTargetScope } from "@executor-js/react/plugins/credential-target-scope"; |
| 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 { Button } from "@executor-js/react/components/button"; | 28 | import { Button } from "@executor-js/react/components/button"; |
| 27 | import { FilterTabs } from "@executor-js/react/components/filter-tabs"; | 29 | import { FilterTabs } from "@executor-js/react/components/filter-tabs"; |
| 28 | import { | 30 | import { SourceOAuthConnectionControl } from "@executor-js/react/plugins/source-oauth-connection"; |
| 29 | CardStack, | ||
| 30 | CardStackContent, | ||
| 31 | CardStackEntryField, | ||
| 32 | } from "@executor-js/react/components/card-stack"; | ||
| 33 | import { Input } from "@executor-js/react/components/input"; | ||
| 34 | import { Badge } from "@executor-js/react/components/badge"; | 31 | import { Badge } from "@executor-js/react/components/badge"; |
| 35 | import { ScopeId } from "@executor-js/sdk/core"; | 32 | import { ScopeId } from "@executor-js/sdk/core"; |
| 33 | import { GraphqlSourceFields } from "./GraphqlSourceFields"; | ||
| 36 | import { | 34 | import { |
| 37 | GRAPHQL_OAUTH_CONNECTION_SLOT, | 35 | GRAPHQL_OAUTH_CONNECTION_SLOT, |
| 38 | type GraphqlCredentialInput, | 36 | type GraphqlCredentialInput, |
| 37 | GraphqlSourceBindingInput, | ||
| 39 | type GraphqlSourceBindingRef, | 38 | type GraphqlSourceBindingRef, |
| 40 | } from "../sdk/types"; | 39 | } from "../sdk/types"; |
| 41 | import type { StoredGraphqlSource } from "../sdk/store"; | 40 | import type { StoredGraphqlSource } from "../sdk/store"; |
| @@ -54,14 +53,23 @@ function EditForm(props: { · 12 unmodified lines | |||
| 54 | onSave: () => void; | 53 | onSave: () => void; |
| 55 | }) { | 54 | }) { |
| 56 | const displayScope = useScope(); | 55 | const displayScope = useScope(); |
| 56 | const scopeStack = useScopeStack(); | ||
| 57 | const sourceScope = ScopeId.make(props.initial.scope); | 57 | const sourceScope = ScopeId.make(props.initial.scope); |
| 58 | const { credentialTargetScope, setCredentialTargetScope, credentialScopeOptions } = | 58 | const { credentialTargetScope, credentialScopeOptions } = useCredentialTargetScope({ |
| 59 | useCredentialTargetScope({ | 59 | sourceScope, |
| 60 | sourceScope, | 60 | initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings), |
| 61 | initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings), | 61 | }); |
| 62 | }); | 62 | const { |
| 63 | credentialTargetScope: oauthCredentialTargetScope, | ||
| 64 | setCredentialTargetScope: setOAuthCredentialTargetScope, | ||
| 65 | } = useCredentialTargetScope({ | ||
| 66 | sourceScope, | ||
| 67 | initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings), | ||
| 68 | }); | ||
| 63 | const doUpdate = useAtomSet(updateGraphqlSource, { mode: "promiseExit" }); | 69 | const doUpdate = useAtomSet(updateGraphqlSource, { mode: "promiseExit" }); |
| 70 | const setBinding = useAtomSet(setGraphqlSourceBinding, { mode: "promise" }); | ||
| 64 | const secretList = useSecretPickerSecrets(); | 71 | const secretList = useSecretPickerSecrets(); |
| 72 | const connectionsResult = useAtomValue(connectionsAtom(displayScope)); | ||
| 65 | 73 | ||
| 66 | const identity = useSourceIdentity({ | 74 | const identity = useSourceIdentity({ |
| 67 | fallbackName: props.initial.name, | 75 | fallbackName: props.initial.name, |
| @@ -84,6 +92,23 @@ function EditForm(props: { · 16 unmodified lines | |||
| 84 | const identityDirty = identity.name.trim() !== props.initial.name.trim(); | 92 | const identityDirty = identity.name.trim() !== props.initial.name.trim(); |
| 85 | const metadataDirty = identityDirty || endpoint.trim() !== props.initial.endpoint.trim(); | 93 | const metadataDirty = identityDirty || endpoint.trim() !== props.initial.endpoint.trim(); |
| 86 | const dirty = metadataDirty || credentialsDirty || authDirty; | 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 | const handleCredentialsChange = (next: HttpCredentialsState) => { | 113 | const handleCredentialsChange = (next: HttpCredentialsState) => { |
| 89 | setCredentials(next); | 114 | setCredentials(next); |
| @@ -164,31 +189,11 @@ function EditForm(props: { · 74 unmodified lines | |||
| 164 | </Badge> | 189 | </Badge> |
| 165 | </div> | 190 | </div> |
| 166 | 191 | ||
| 167 | <SourceIdentityFields identity={identity} namespaceReadOnly /> | 192 | <GraphqlSourceFields |
| 168 | 193 | endpoint={endpoint} | |
| 169 | <CardStack> | 194 | onEndpointChange={setEndpoint} |
| 170 | <CardStackContent className="border-t-0"> | 195 | identity={identity} |
| 171 | <CardStackEntryField label="Endpoint"> | 196 | namespaceReadOnly |
| 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 | /> | 197 | /> |
| 193 | 198 | ||
| 194 | <HttpCredentialsEditor | 199 | <HttpCredentialsEditor |
| @@ -224,6 +229,37 @@ function EditForm(props: { · 29 unmodified lines | |||
| 224 | )} | 229 | )} |
| 225 | </section> | 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 | {error && ( | 263 | {error && ( |
| 228 | <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2"> | 264 | <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2"> |
| 229 | <p className="text-sm text-destructive">{error}</p> | 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 | export default function GraphqlSourceSummary(props: { | 35 | export default function GraphqlSourceSummary(props: { |
| 2 | sourceId: string; | 36 | sourceId: string; |
| 3 | variant?: "badge" | "panel"; | 37 | variant?: "badge" | "panel"; |
| 38 | onAction?: () => void; | ||
| 4 | }) { | 39 | }) { |
| 5 | if (props.variant === "panel") return null; | 40 | const displayScope = useScope(); |
| 6 | return <span>GraphQL · {props.sourceId}</span>; | 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 | const importAdd = () => import("./AddGraphqlSource"); | 5 | const importAdd = () => import("./AddGraphqlSource"); |
| 6 | const importEdit = () => import("./EditGraphqlSource"); | 6 | const importEdit = () => import("./EditGraphqlSource"); |
| 7 | const importSummary = () => import("./GraphqlSourceSummary"); | 7 | const importSummary = () => import("./GraphqlSourceSummary"); |
| 8 | const importSignIn = () => import("./GraphqlSignInButton"); | ||
| 9 | 8 | ||
| 10 | export const graphqlSourcePlugin: SourcePlugin = { | 9 | export const graphqlSourcePlugin: SourcePlugin = { |
| 11 | key: "graphql", | 10 | key: "graphql", |
| @@ -13,12 +12,10 @@ export const graphqlSourcePlugin: SourcePlugin = { · 1 unmodified line | |||
| 13 | add: lazy(importAdd), | 12 | add: lazy(importAdd), |
| 14 | edit: lazy(importEdit), | 13 | edit: lazy(importEdit), |
| 15 | summary: lazy(importSummary), | 14 | summary: lazy(importSummary), |
| 16 | signIn: lazy(importSignIn), | ||
| 17 | presets: graphqlPresets, | 15 | presets: graphqlPresets, |
| 18 | preload: () => { | 16 | preload: () => { |
| 19 | void importAdd(); | 17 | void importAdd(); |
| 20 | void importEdit(); | 18 | void importEdit(); |
| 21 | void importSummary(); | 19 | void importSummary(); |
| 22 | void importSignIn(); | ||
| 23 | }, | 20 | }, |
| 24 | }; | 21 | }; |
| @@ -10,22 +10,14 @@ import { · 9 unmodified lines | |||
| 10 | CardStack, | 10 | CardStack, |
| 11 | CardStackContent, | 11 | CardStackContent, |
| 12 | CardStackEntry, | 12 | CardStackEntry, |
| 13 | CardStackEntryActions, | ||
| 14 | CardStackEntryContent, | ||
| 15 | CardStackEntryDescription, | ||
| 16 | CardStackEntryField, | 13 | CardStackEntryField, |
| 17 | CardStackEntryMedia, | ||
| 18 | CardStackEntryTitle, | ||
| 19 | } from "@executor-js/react/components/card-stack"; | 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 | import { FilterTabs } from "@executor-js/react/components/filter-tabs"; | 16 | import { FilterTabs } from "@executor-js/react/components/filter-tabs"; |
| 22 | import { FloatActions } from "@executor-js/react/components/float-actions"; | 17 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 23 | import { Input } from "@executor-js/react/components/input"; | 18 | import { Input } from "@executor-js/react/components/input"; |
| 24 | import { Label } from "@executor-js/react/components/label"; | 19 | import { Label } from "@executor-js/react/components/label"; |
| 25 | import { Badge } from "@executor-js/react/components/badge"; | 20 | import { Spinner } from "@executor-js/react/components/spinner"; |
| 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"; | ||
| 29 | import { Textarea } from "@executor-js/react/components/textarea"; | 21 | import { Textarea } from "@executor-js/react/components/textarea"; |
| 30 | import { | 22 | import { |
| 31 | emptyHttpCredentials, | 23 | emptyHttpCredentials, |
| @@ -37,7 +29,6 @@ import { · 5 unmodified lines | |||
| 37 | import { | 29 | import { |
| 38 | sourceDisplayNameFromUrl, | 30 | sourceDisplayNameFromUrl, |
| 39 | slugifyNamespace, | 31 | slugifyNamespace, |
| 40 | SourceIdentityFieldRows, | ||
| 41 | SourceIdentityFields, | 32 | SourceIdentityFields, |
| 42 | useSourceIdentity, | 33 | useSourceIdentity, |
| 43 | } from "@executor-js/react/plugins/source-identity"; | 34 | } from "@executor-js/react/plugins/source-identity"; |
| @@ -57,6 +48,7 @@ import { · 13 unmodified lines | |||
| 57 | type RemoteAuthMode = "none" | "oauth2"; | 48 | type RemoteAuthMode = "none" | "oauth2"; |
| 58 | import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; | 49 | import { sourceWriteKeys } from "@executor-js/react/api/reactivity-keys"; |
| 59 | import { probeMcpEndpoint, addMcpSourceOptimistic } from "./atoms"; | 50 | import { probeMcpEndpoint, addMcpSourceOptimistic } from "./atoms"; |
| 51 | import { McpRemoteSourceFields } from "./McpRemoteSourceFields"; | ||
| 60 | import { mcpPresets, type McpPreset } from "../sdk/presets"; | 52 | import { mcpPresets, type McpPreset } from "../sdk/presets"; |
| 61 | import { MCP_OAUTH_CONNECTION_SLOT, type McpCredentialInput } from "../sdk/types"; | 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 | {transport === "remote" ? ( | 592 | {transport === "remote" ? ( |
| 601 | <> | 593 | <> |
| 602 | {/* Server info card (shown above URL input after probing) */} | 594 | <McpRemoteSourceFields |
| 603 | {probe ? ( | 595 | url={state.url} |
| 604 | <CardStack> | 596 | onUrlChange={(url) => dispatch({ type: "set-url", url })} |
| 605 | <CardStackContent className="border-t-0"> | 597 | identity={remoteIdentity} |
| 606 | <CardStackEntry> | 598 | preview={probe} |
| 607 | <CardStackEntryMedia> | 599 | probing={isProbing} |
| 608 | <SourceFavicon url={state.url} size={32} /> | 600 | error={probeError} |
| 609 | </CardStackEntryMedia> | 601 | onRetry={handleProbe} |
| 610 | <CardStackEntryContent> | 602 | /> |
| 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 | )} | ||
| 712 | 603 | ||
| 713 | <HttpCredentialsEditor | 604 | <HttpCredentialsEditor |
| 714 | credentials={remoteCredentials} | 605 | credentials={remoteCredentials} |
| @@ -2,37 +2,39 @@ import { useState } from "react"; · 1 unmodified line | |||
| 2 | import { useAtomValue, useAtomSet } from "@effect/atom-react"; | 2 | import { useAtomValue, useAtomSet } from "@effect/atom-react"; |
| 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; | 3 | import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; |
| 4 | import * as Exit from "effect/Exit"; | 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 | import { | 5 | import { |
| 9 | SourceIdentityFields, | 6 | mcpSourceAtom, |
| 10 | useSourceIdentity, | 7 | mcpSourceBindingsAtom, |
| 11 | } from "@executor-js/react/plugins/source-identity"; | 8 | setMcpSourceBinding, |
| 12 | import { | 9 | updateMcpSource, |
| 13 | CredentialTargetScopeSelector, | 10 | } from "./atoms"; |
| 14 | useCredentialTargetScope, | 11 | import { connectionsAtom } from "@executor-js/react/api/atoms"; |
| 15 | } from "@executor-js/react/plugins/credential-target-scope"; | 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 | import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets"; | 16 | import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets"; |
| 17 | import { | 17 | import { |
| 18 | HttpCredentialsEditor, | 18 | HttpCredentialsEditor, |
| 19 | serializeHttpCredentials, | ||
| 19 | serializeScopedHttpCredentials, | 20 | serializeScopedHttpCredentials, |
| 20 | type HttpCredentialsState, | 21 | type HttpCredentialsState, |
| 21 | } from "@executor-js/react/plugins/http-credentials"; | 22 | } from "@executor-js/react/plugins/http-credentials"; |
| 22 | import { | 23 | import { |
| 24 | effectiveCredentialBindingForScope, | ||
| 23 | httpCredentialsFromConfiguredCredentialBindings, | 25 | httpCredentialsFromConfiguredCredentialBindings, |
| 24 | initialCredentialTargetScope, | 26 | initialCredentialTargetScope, |
| 25 | } from "@executor-js/react/plugins/credential-bindings"; | 27 | } from "@executor-js/react/plugins/credential-bindings"; |
| 28 | import { SourceOAuthConnectionControl } from "@executor-js/react/plugins/source-oauth-connection"; | ||
| 26 | import { Button } from "@executor-js/react/components/button"; | 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 | import { Badge } from "@executor-js/react/components/badge"; | 30 | import { Badge } from "@executor-js/react/components/badge"; |
| 34 | import { ScopeId } from "@executor-js/sdk/core"; | 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 | import type { McpStoredSourceSchemaType } from "../sdk/stored-source"; | 38 | import type { McpStoredSourceSchemaType } from "../sdk/stored-source"; |
| 37 | 39 | ||
| 38 | // --------------------------------------------------------------------------- | 40 | // --------------------------------------------------------------------------- |
| @@ -46,14 +48,23 @@ function RemoteEditForm(props: { · 7 unmodified lines | |||
| 46 | onSave: () => void; | 48 | onSave: () => void; |
| 47 | }) { | 49 | }) { |
| 48 | const displayScope = useScope(); | 50 | const displayScope = useScope(); |
| 51 | const scopeStack = useScopeStack(); | ||
| 49 | const sourceScope = ScopeId.make(props.initial.scope); | 52 | const sourceScope = ScopeId.make(props.initial.scope); |
| 50 | const { credentialTargetScope, setCredentialTargetScope, credentialScopeOptions } = | 53 | const { credentialTargetScope, credentialScopeOptions } = useCredentialTargetScope({ |
| 51 | useCredentialTargetScope({ | 54 | sourceScope, |
| 52 | sourceScope, | 55 | initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings), |
| 53 | initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings), | 56 | }); |
| 54 | }); | 57 | const { |
| 58 | credentialTargetScope: oauthCredentialTargetScope, | ||
| 59 | setCredentialTargetScope: setOAuthCredentialTargetScope, | ||
| 60 | } = useCredentialTargetScope({ | ||
| 61 | sourceScope, | ||
| 62 | initialTargetScope: initialCredentialTargetScope(sourceScope, props.bindings), | ||
| 63 | }); | ||
| 55 | const doUpdate = useAtomSet(updateMcpSource, { mode: "promiseExit" }); | 64 | const doUpdate = useAtomSet(updateMcpSource, { mode: "promiseExit" }); |
| 65 | const setBinding = useAtomSet(setMcpSourceBinding, { mode: "promise" }); | ||
| 56 | const secretList = useSecretPickerSecrets(); | 66 | const secretList = useSecretPickerSecrets(); |
| 67 | const connectionsResult = useAtomValue(connectionsAtom(displayScope)); | ||
| 57 | 68 | ||
| 58 | const identity = useSourceIdentity({ | 69 | const identity = useSourceIdentity({ |
| 59 | fallbackName: props.initial.name, | 70 | fallbackName: props.initial.name, |
| @@ -74,6 +85,23 @@ function RemoteEditForm(props: { · 14 unmodified lines | |||
| 74 | const identityDirty = identity.name.trim() !== props.initial.name.trim(); | 85 | const identityDirty = identity.name.trim() !== props.initial.name.trim(); |
| 75 | const metadataDirty = identityDirty || endpoint.trim() !== props.initial.config.endpoint.trim(); | 86 | const metadataDirty = identityDirty || endpoint.trim() !== props.initial.config.endpoint.trim(); |
| 76 | const dirty = metadataDirty || credentialsDirty; | 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 | const handleCredentialsChange = (next: HttpCredentialsState) => { | 106 | const handleCredentialsChange = (next: HttpCredentialsState) => { |
| 79 | setCredentials(next); | 107 | setCredentials(next); |
| @@ -137,32 +165,17 @@ function RemoteEditForm(props: { · 57 unmodified lines | |||
| 137 | </Badge> | 165 | </Badge> |
| 138 | </div> | 166 | </div> |
| 139 | 167 | ||
| 140 | <SourceIdentityFields identity={identity} namespaceReadOnly /> | 168 | <McpRemoteSourceFields |
| 141 | 169 | url={endpoint} | |
| 142 | {/* Endpoint */} | 170 | onUrlChange={setEndpoint} |
| 143 | <CardStack> | 171 | identity={identity} |
| 144 | <CardStackContent className="border-t-0"> | 172 | preview={{ |
| 145 | <CardStackEntryField label="Endpoint"> | 173 | name: props.initial.name, |
| 146 | <Input | 174 | serverName: props.initial.name, |
| 147 | value={endpoint} | 175 | connected: true, |
| 148 | onChange={(e) => { | 176 | toolCount: null, |
| 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); | ||
| 164 | }} | 177 | }} |
| 165 | description="Choose where updated MCP credentials are saved." | 178 | namespaceReadOnly |
| 166 | /> | 179 | /> |
| 167 | 180 | ||
| 168 | <HttpCredentialsEditor | 181 | <HttpCredentialsEditor |
| @@ -175,6 +188,39 @@ function RemoteEditForm(props: { · 6 unmodified lines | |||
| 175 | bindingScopeOptions={credentialScopeOptions} | 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 | {error && ( | 224 | {error && ( |
| 179 | <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2"> | 225 | <div className="rounded-lg border border-destructive/30 bg-destructive/5 px-3 py-2"> |
| 180 | <p className="text-sm text-destructive">{error}</p> | 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 | const importAdd = () => import("./AddMcpSource"); | 5 | const importAdd = () => import("./AddMcpSource"); |
| 6 | const importEdit = () => import("./EditMcpSource"); | 6 | const importEdit = () => import("./EditMcpSource"); |
| 7 | const importSignIn = () => import("./McpSignInButton"); | 7 | const importSummary = () => import("./McpSourceSummary"); |
| 8 | 8 | ||
| 9 | const LazyAddMcpSource = lazy(importAdd); | 9 | const LazyAddMcpSource = lazy(importAdd); |
| 10 | const LazyEditMcpSource = lazy(importEdit); | 10 | const LazyEditMcpSource = lazy(importEdit); |
| 11 | const LazyMcpSignInButton = lazy(importSignIn); | 11 | const LazyMcpSourceSummary = lazy(importSummary); |
| 12 | 12 | ||
| 13 | type AddProps = ComponentProps<SourcePlugin["add"]>; | 13 | type AddProps = ComponentProps<SourcePlugin["add"]>; |
| 14 | 14 | ||
| @@ -41,12 +41,12 @@ export const createMcpSourcePlugin = (options?: McpSourcePluginOptions): SourceP · 26 unmodified lines | |||
| 41 | label: "MCP", | 41 | label: "MCP", |
| 42 | add: AddWithFlag, | 42 | add: AddWithFlag, |
| 43 | edit: LazyEditMcpSource, | 43 | edit: LazyEditMcpSource, |
| 44 | signIn: LazyMcpSignInButton, | 44 | summary: LazyMcpSourceSummary, |
| 45 | presets, | 45 | presets, |
| 46 | preload: () => { | 46 | preload: () => { |
| 47 | void importAdd(); | 47 | void importAdd(); |
| 48 | void importEdit(); | 48 | void importEdit(); |
| 49 | void importSignIn(); | 49 | void importSummary(); |
| 50 | }, | 50 | }, |
| 51 | }; | 51 | }; |
| 52 | }; | 52 | }; |
| @@ -34,11 +34,7 @@ import { · 33 unmodified lines | |||
| 34 | type HeaderState, | 34 | type HeaderState, |
| 35 | } from "@executor-js/react/plugins/secret-header-auth"; | 35 | } from "@executor-js/react/plugins/secret-header-auth"; |
| 36 | import { CredentialScopeDropdown } from "@executor-js/react/plugins/credential-target-scope"; | 36 | import { CredentialScopeDropdown } from "@executor-js/react/plugins/credential-target-scope"; |
| 37 | import { | 37 | import { slugifyNamespace, useSourceIdentity } from "@executor-js/react/plugins/source-identity"; |
| 38 | slugifyNamespace, | ||
| 39 | SourceIdentityFieldRows, | ||
| 40 | useSourceIdentity, | ||
| 41 | } from "@executor-js/react/plugins/source-identity"; | ||
| 42 | import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets"; | 38 | import { useSecretPickerSecrets } from "@executor-js/react/plugins/use-secret-picker-secrets"; |
| 43 | import { Button } from "@executor-js/react/components/button"; | 39 | import { Button } from "@executor-js/react/components/button"; |
| 44 | import { CopyButton } from "@executor-js/react/components/copy-button"; | 40 | import { CopyButton } from "@executor-js/react/components/copy-button"; |
| @@ -47,27 +43,21 @@ import { · 2 unmodified lines | |||
| 47 | CollapsibleContent, | 43 | CollapsibleContent, |
| 48 | CollapsibleTrigger, | 44 | CollapsibleTrigger, |
| 49 | } from "@executor-js/react/components/collapsible"; | 45 | } from "@executor-js/react/components/collapsible"; |
| 50 | import { FreeformCombobox } from "@executor-js/react/components/combobox"; | ||
| 51 | import { | 46 | import { |
| 52 | CardStack, | 47 | CardStack, |
| 53 | CardStackContent, | 48 | CardStackContent, |
| 54 | CardStackEntry, | ||
| 55 | CardStackEntryContent, | ||
| 56 | CardStackEntryDescription, | ||
| 57 | CardStackEntryField, | 49 | CardStackEntryField, |
| 58 | CardStackEntryTitle, | ||
| 59 | } from "@executor-js/react/components/card-stack"; | 50 | } from "@executor-js/react/components/card-stack"; |
| 60 | import { FieldLabel } from "@executor-js/react/components/field"; | 51 | import { FieldLabel } from "@executor-js/react/components/field"; |
| 61 | import { FloatActions } from "@executor-js/react/components/float-actions"; | 52 | import { FloatActions } from "@executor-js/react/components/float-actions"; |
| 62 | import { HelpTooltip } from "@executor-js/react/components/help-tooltip"; | 53 | import { HelpTooltip } from "@executor-js/react/components/help-tooltip"; |
| 63 | import { Input } from "@executor-js/react/components/input"; | ||
| 64 | import { Label } from "@executor-js/react/components/label"; | 54 | import { Label } from "@executor-js/react/components/label"; |
| 65 | import { Textarea } from "@executor-js/react/components/textarea"; | 55 | import { Textarea } from "@executor-js/react/components/textarea"; |
| 66 | import { Checkbox } from "@executor-js/react/components/checkbox"; | 56 | import { Checkbox } from "@executor-js/react/components/checkbox"; |
| 67 | import { SourceFavicon } from "@executor-js/react/components/source-favicon"; | ||
| 68 | import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group"; | 57 | import { RadioGroup, RadioGroupItem } from "@executor-js/react/components/radio-group"; |
| 69 | import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; | 58 | import { IOSSpinner, Spinner } from "@executor-js/react/components/spinner"; |
| 70 | import { addOpenApiSpecOptimistic, previewOpenApiSpec, setOpenApiSourceBinding } from "./atoms"; | 59 | import { addOpenApiSpecOptimistic, previewOpenApiSpec, setOpenApiSourceBinding } from "./atoms"; |
| 60 | import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields"; | ||
| 71 | import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview"; | 61 | import type { SpecPreview, HeaderPreset, OAuth2Preset } from "../sdk/preview"; |
| 72 | import { | 62 | import { |
| 73 | headerBindingSlot, | 63 | headerBindingSlot, |
| @@ -936,61 +926,32 @@ export default function AddOpenApiSource(props: { · 862 unmodified lines | |||
| 936 | 926 | ||
| 937 | {/* ── Source information card (shown after analysis) ── */} | 927 | {/* ── Source information card (shown after analysis) ── */} |
| 938 | {preview ? ( | 928 | {preview ? ( |
| 939 | <CardStack> | 929 | <OpenApiSourceDetailsFields |
| 940 | <CardStackContent className="border-t-0"> | 930 | title={Option.getOrElse(preview.title, () => "API")} |
| 941 | <CardStackEntry> | 931 | description={`${Option.getOrElse(preview.version, () => "")}${ |
| 942 | {resolvedBaseUrl && <SourceFavicon url={resolvedBaseUrl} size={16} />} | 932 | Option.isSome(preview.version) ? " · " : "" |
| 943 | <CardStackEntryContent> | 933 | }${preview.operationCount} operation${preview.operationCount !== 1 ? "s" : ""}${ |
| 944 | <CardStackEntryTitle> | 934 | preview.tags.length > 0 |
| 945 | {Option.getOrElse(preview.title, () => "API")} | 935 | ? ` · ${preview.tags.length} tag${preview.tags.length !== 1 ? "s" : ""}` |
| 946 | </CardStackEntryTitle> | 936 | : "" |
| 947 | <CardStackEntryDescription> | 937 | }`} |
| 948 | {Option.getOrElse(preview.version, () => "")} | 938 | identity={identity} |
| 949 | {Option.isSome(preview.version) && " · "} | 939 | baseUrl={resolvedBaseUrl} |
| 950 | {preview.operationCount} operation | 940 | onBaseUrlChange={setBaseUrl} |
| 951 | {preview.operationCount !== 1 ? "s" : ""} | 941 | baseUrlOptions={baseUrlOptions} |
| 952 | {preview.tags.length > 0 && | 942 | specUrl={specUrl} |
| 953 | ` · ${preview.tags.length} tag${preview.tags.length !== 1 ? "s" : ""}`} | 943 | onSpecUrlChange={(value) => { |
| 954 | </CardStackEntryDescription> | 944 | setSpecUrl(value); |
| 955 | </CardStackEntryContent> | 945 | setPreview(null); |
| 956 | </CardStackEntry> | 946 | setBaseUrl(""); |
| 957 | <SourceIdentityFieldRows identity={identity} /> | 947 | setCustomHeaders([]); |
| 958 | <div className="grid grid-cols-1 md:grid-cols-2"> | 948 | setStrategy({ kind: "none" }); |
| 959 | <CardStackEntryField label="Base URL"> | 949 | setOauth2AuthState(null); |
| 960 | <FreeformCombobox | 950 | setOauth2Error(null); |
| 961 | value={resolvedBaseUrl} | 951 | }} |
| 962 | onValueChange={setBaseUrl} | 952 | faviconUrl={resolvedBaseUrl} |
| 963 | options={baseUrlOptions} | 953 | baseUrlMissingMessage="A base URL is required to make requests." |
| 964 | placeholder="https://api.example.com" | 954 | /> |
| 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> | ||
| 994 | ) : null} | 955 | ) : null} |
| 995 | 956 | ||
| 996 | {analyzeError && ( | 957 | {analyzeError && ( |
| @@ -44,6 +44,7 @@ import { · 43 unmodified lines | |||
| 44 | setOpenApiSourceBinding, | 44 | setOpenApiSourceBinding, |
| 45 | updateOpenApiSource, | 45 | updateOpenApiSource, |
| 46 | } from "./atoms"; | 46 | } from "./atoms"; |
| 47 | import { OpenApiSourceDetailsFields } from "./OpenApiSourceDetailsFields"; | ||
| 47 | import { | 48 | import { |
| 48 | OPENAPI_OAUTH_CALLBACK_PATH, | 49 | OPENAPI_OAUTH_CALLBACK_PATH, |
| 49 | OPENAPI_OAUTH_POPUP_NAME, | 50 | OPENAPI_OAUTH_POPUP_NAME, |
| @@ -178,6 +179,16 @@ export default function EditOpenApiSource(props: { · 128 unmodified lines | |||
| 178 | const [oauth2EndpointsSaveState, setOAuth2EndpointsSaveState] = useState< | 179 | const [oauth2EndpointsSaveState, setOAuth2EndpointsSaveState] = useState< |
| 179 | "idle" | "saving" | "saved" | 180 | "idle" | "saving" | "saved" |
| 180 | >("idle"); | 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 | const sourceSaveSeq = useRef(0); | 192 | const sourceSaveSeq = useRef(0); |
| 182 | const oauth2EndpointsSaveSeq = useRef(0); | 193 | const oauth2EndpointsSaveSeq = useRef(0); |
| 183 | 194 | ||
| @@ -580,47 +591,27 @@ export default function EditOpenApiSource(props: { · 396 unmodified lines | |||
| 580 | <h1 className="text-xl font-semibold text-foreground">OpenAPI Source</h1> | 591 | <h1 className="text-xl font-semibold text-foreground">OpenAPI Source</h1> |
| 581 | </div> | 592 | </div> |
| 582 | 593 | ||
| 583 | <CardStack> | 594 | <OpenApiSourceDetailsFields |
| 584 | <CardStackContent className="border-t-0"> | 595 | title="Source Details" |
| 585 | <CardStackEntry> | 596 | description="Name and base URL save automatically." |
| 586 | <CardStackEntryContent> | 597 | identity={editIdentity} |
| 587 | <CardStackEntryTitle>Source Details</CardStackEntryTitle> | 598 | baseUrl={baseUrl} |
| 588 | <CardStackEntryDescription> | 599 | onBaseUrlChange={setBaseUrl} |
| 589 | Name and base URL save automatically. | 600 | specUrl={source.config.sourceUrl ?? ""} |
| 590 | </CardStackEntryDescription> | 601 | onSpecUrlChange={() => {}} |
| 591 | </CardStackEntryContent> | 602 | specUrlDisabled |
| 592 | {sourceSaveState !== "idle" && ( | 603 | namespaceReadOnly |
| 593 | <span className="text-xs text-muted-foreground"> | 604 | saveState={sourceSaveState} |
| 594 | {sourceSaveState === "saving" ? "Saving…" : "Saved"} | 605 | footer={ |
| 595 | </span> | 606 | source.config.oauth2 |
| 596 | )} | 607 | ? `Authentication Template: OAuth2 ${source.config.oauth2.flow}` |
| 597 | </CardStackEntry> | 608 | : Object.keys(source.config.headers ?? {}).length > 0 |
| 598 | <CardStackEntryField label="Name"> | 609 | ? `Authentication Template: ${Object.keys(source.config.headers ?? {}).length} header binding${ |
| 599 | <Input value={name} onChange={(e) => setName((e.target as HTMLInputElement).value)} /> | 610 | Object.keys(source.config.headers ?? {}).length === 1 ? "" : "s" |
| 600 | </CardStackEntryField> | 611 | }` |
| 601 | <CardStackEntryField label="Base URL"> | 612 | : "Authentication Template: None" |
| 602 | <Input | 613 | } |
| 603 | value={baseUrl} | 614 | /> |
| 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> | ||
| 624 | 615 | ||
| 625 | <CardStack> | 616 | <CardStack> |
| 626 | <CardStackContent className="border-t-0"> | 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 | import { connectionsAtom, sourceAtom } from "@executor-js/react/api/atoms"; | 4 | import { connectionsAtom, sourceAtom } from "@executor-js/react/api/atoms"; |
| 5 | import { Badge } from "@executor-js/react/components/badge"; | 5 | import { Badge } from "@executor-js/react/components/badge"; |
| 6 | import { Button } from "@executor-js/react/components/button"; | ||
| 7 | import { useScope, useScopeStack, useUserScope } from "@executor-js/react/api/scope-context"; | 6 | import { useScope, useScopeStack, useUserScope } from "@executor-js/react/api/scope-context"; |
| 8 | import { ScopeId } from "@executor-js/sdk/core"; | 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 | import { openApiSourceAtom, openApiSourceBindingsAtom } from "./atoms"; | 15 | import { openApiSourceAtom, openApiSourceBindingsAtom } from "./atoms"; |
| 11 | import { effectiveBindingForScope, missingCredentialLabels } from "../sdk/credential-status"; | 16 | import { effectiveBindingForScope } from "../sdk/credential-status"; |
| 12 | 17 | import { oauth2ClientSecretSlot, type StoredSourceSchemaType } from "../sdk/store"; | |
| 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 | } | ||
| 23 | 18 | ||
| 24 | function OAuthBadge() { | 19 | function OAuthBadge() { |
| 25 | return <Badge variant="secondary">OAuth</Badge>; | 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 | function CheckingCredentialsBadge() { | 23 | function CheckingCredentialsBadge() { |
| 40 | return ( | 24 | return ( |
| 41 | <Badge | 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 | // The entry row already renders name + id + kind, so this summary | 64 | // The entry row already renders name + id + kind, so this summary |
| 51 | // component only contributes extras — specifically, an OAuth status | 65 | // component only contributes extras — specifically, an OAuth status |
| 52 | // badge when the source has OAuth2 configured. Non-OAuth sources | 66 | // badge when the source has OAuth2 configured. Non-OAuth sources |
| @@ -89,34 +103,19 @@ export default function OpenApiSourceSummary(props: { · 36 unmodified lines | |||
| 89 | const liveConnectionIds = new Set(connections.map((connection) => connection.id)); | 103 | const liveConnectionIds = new Set(connections.map((connection) => connection.id)); |
| 90 | const scopeRanks = new Map(scopeStack.map((scope, index) => [scope.id, index] as const)); | 104 | const scopeRanks = new Map(scopeStack.map((scope, index) => [scope.id, index] as const)); |
| 91 | const credentialTargetScope = userScope; | 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 | liveConnectionIds, | 111 | liveConnectionIds, |
| 94 | }); | 112 | }); |
| 95 | 113 | ||
| 96 | if (props.variant === "panel") { | 114 | if (props.variant === "panel") { |
| 97 | if (missing.length === 0) return null; | 115 | return <SourceCredentialNotice missing={missing} onAction={props.onAction} />; |
| 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 | ); | ||
| 117 | } | 116 | } |
| 118 | 117 | ||
| 119 | if (missing.length > 0) return <NeedsCredentialsBadge />; | 118 | if (missing.length > 0) return <SourceCredentialStatusBadge missing={missing} />; |
| 120 | 119 | ||
| 121 | if (!oauth2) return null; | 120 | if (!oauth2) return null; |
| 122 | const connectionBinding = effectiveBindingForScope( | 121 | const connectionBinding = effectiveBindingForScope( |
| @@ -131,7 +130,7 @@ export default function OpenApiSourceSummary(props: { · 8 unmodified lines | |||
| 131 | : null; | 130 | : null; |
| 132 | 131 | ||
| 133 | if (connectionId && connections.some((connection) => connection.id === connectionId)) { | 132 | if (connectionId && connections.some((connection) => connection.id === connectionId)) { |
| 134 | return <ConnectedBadge />; | 133 | return <SourceCredentialStatusBadge missing={[]} />; |
| 135 | } | 134 | } |
| 136 | 135 | ||
| 137 | return <OAuthBadge />; | 136 | return <OAuthBadge />; |
| @@ -166,12 +166,6 @@ export function SourceDetailPage(props: { namespace: string }) { · 165 unmodified lines | |||
| 166 | </div> | 166 | </div> |
| 167 | 167 | ||
| 168 | <div className="flex shrink-0 items-center gap-2"> | 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 | {canEdit && editPlugin && !editing && !confirmDelete && ( | 169 | {canEdit && editPlugin && !editing && !confirmDelete && ( |
| 176 | <Button variant="outline" size="sm" onClick={() => setEditing(true)}> | 170 | <Button variant="outline" size="sm" onClick={() => setEditing(true)}> |
| 177 | Edit | 171 | Edit |
| @@ -170,12 +170,15 @@ export function CredentialScopeDropdown(props: { · 169 unmodified lines | |||
| 170 | readonly onChange: (scope: ScopeId) => void; | 170 | readonly onChange: (scope: ScopeId) => void; |
| 171 | readonly label?: string; | 171 | readonly label?: string; |
| 172 | readonly help?: ReactNode; | 172 | readonly help?: ReactNode; |
| 173 | readonly className?: string; | ||
| 174 | readonly triggerClassName?: string; | ||
| 175 | readonly size?: "sm" | "default"; | ||
| 173 | }) { | 176 | }) { |
| 174 | const label = props.label ?? "Used by"; | 177 | const label = props.label ?? "Used by"; |
| 175 | if (props.options.length <= 1) return null; | 178 | if (props.options.length <= 1) return null; |
| 176 | 179 | ||
| 177 | return ( | 180 | return ( |
| 178 | <div className="space-y-1.5"> | 181 | <div className={props.className ?? "space-y-1.5"}> |
| 179 | <div className="flex items-center gap-1.5"> | 182 | <div className="flex items-center gap-1.5"> |
| 180 | <FieldLabel className="text-[11px]">{label}</FieldLabel> | 183 | <FieldLabel className="text-[11px]">{label}</FieldLabel> |
| 181 | <HelpTooltip label={label}> | 184 | <HelpTooltip label={label}> |
| @@ -186,7 +189,7 @@ export function CredentialScopeDropdown(props: { · 4 unmodified lines | |||
| 186 | value={String(props.value)} | 189 | value={String(props.value)} |
| 187 | onValueChange={(value) => props.onChange(ScopeId.make(value))} | 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 | <SelectValue placeholder={label} /> | 193 | <SelectValue placeholder={label} /> |
| 191 | </SelectTrigger> | 194 | </SelectTrigger> |
| 192 | <SelectContent> | 195 | <SelectContent> |
| @@ -230,7 +233,7 @@ export function CredentialUsageRow(props: { · 37 unmodified lines | |||
| 230 | } | 233 | } |
| 231 | 234 | ||
| 232 | return ( | 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 | <div className="min-w-0 space-y-2.5">{props.children}</div> | 237 | <div className="min-w-0 space-y-2.5">{props.children}</div> |
| 235 | <CredentialScopeDropdown | 238 | <CredentialScopeDropdown |
| 236 | value={props.value} | 239 | value={props.value} |
| @@ -238,6 +241,8 @@ export function CredentialUsageRow(props: { · 1 unmodified line | |||
| 238 | onChange={props.onChange} | 241 | onChange={props.onChange} |
| 239 | label={props.label} | 242 | label={props.label} |
| 240 | help={props.help} | 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 | </div> | 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 | } | ||