From da71cb6f7b3fa877efc699c448fdc4d90cf9ec52 Mon Sep 17 00:00:00 2001 From: dexhorthy Date: Sun, 10 May 2026 15:48:20 -0700 Subject: [PATCH 1/6] Add tooltips to sidebar source names for truncated text Wrap source name spans with Radix Tooltip in both local and cloud shell sidebars so hovering reveals the full name when text is truncated. --- apps/cloud/src/web/shell.tsx | 67 +++++++++++++++++++++--------------- apps/local/src/web/shell.tsx | 67 +++++++++++++++++++++--------------- 2 files changed, 80 insertions(+), 54 deletions(-) diff --git a/apps/cloud/src/web/shell.tsx b/apps/cloud/src/web/shell.tsx index 23d016953..d2e92e1b8 100644 --- a/apps/cloud/src/web/shell.tsx +++ b/apps/cloud/src/web/shell.tsx @@ -30,6 +30,12 @@ import { } from "@executor-js/react/components/dropdown-menu"; import { SourceFavicon } from "@executor-js/react/components/source-favicon"; import { CommandPalette } from "@executor-js/react/components/command-palette"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@executor-js/react/components/tooltip"; import { authWriteKeys } from "@executor-js/react/api/reactivity-keys"; import { AUTH_PATHS } from "../auth/api"; import { organizationsAtom, switchOrganization, useAuth } from "./auth"; @@ -96,33 +102,40 @@ function SourceList(props: { pathname: string; onNavigate?: () => void }) { No sources yet ) : ( -
- {value.map((s) => { - const detailPath = `/sources/${s.id}`; - const active = - props.pathname === detailPath || props.pathname.startsWith(`${detailPath}/`); - return ( - - - {s.name} - - {s.kind} - - - ); - })} -
+ +
+ {value.map((s) => { + const detailPath = `/sources/${s.id}`; + const active = + props.pathname === detailPath || props.pathname.startsWith(`${detailPath}/`); + return ( + + + + + {s.name} + + {s.name} + + + {s.kind} + + + ); + })} +
+
), }); } diff --git a/apps/local/src/web/shell.tsx b/apps/local/src/web/shell.tsx index c9e2777c6..503132f93 100644 --- a/apps/local/src/web/shell.tsx +++ b/apps/local/src/web/shell.tsx @@ -9,6 +9,12 @@ import { useScope, useScopeInfo } from "@executor-js/react/api/scope-context"; import { Button } from "@executor-js/react/components/button"; import { SourceFavicon } from "@executor-js/react/components/source-favicon"; import { CommandPalette } from "@executor-js/react/components/command-palette"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@executor-js/react/components/tooltip"; import { useClientPlugins } from "@executor-js/sdk/client"; // ── Env ───────────────────────────────────────────────────────────────── @@ -280,33 +286,40 @@ function SourceList(props: { pathname: string; onNavigate?: () => void }) { No sources yet ) : ( -
- {value.map((s) => { - const detailPath = `/sources/${s.id}`; - const active = - props.pathname === detailPath || props.pathname.startsWith(`${detailPath}/`); - return ( - - - {s.name} - - {s.kind} - - - ); - })} -
+ +
+ {value.map((s) => { + const detailPath = `/sources/${s.id}`; + const active = + props.pathname === detailPath || props.pathname.startsWith(`${detailPath}/`); + return ( + + + + + {s.name} + + {s.name} + + + {s.kind} + + + ); + })} +
+
), }); } From 4bd3fc941f68c3f0252efebba6acc6e1684d8654 Mon Sep 17 00:00:00 2001 From: dexhorthy Date: Sun, 10 May 2026 15:53:01 -0700 Subject: [PATCH 2/6] Preserve user-customized MCP source names on refresh Reorder the name fallback chain so existing user names take precedence over server-reported names during refresh operations. --- packages/plugins/mcp/src/sdk/plugin.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index 07ba4d1f0..4409b250d 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -1395,7 +1395,7 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { ); const existing = yield* ctx.storage.getSource(namespace, scope); - const sourceName = manifest.server?.name ?? existing?.name ?? namespace; + const sourceName = existing?.name ?? manifest.server?.name ?? namespace; yield* ctx .transaction( From bb2b1e1976d5a56f02653b33a46520ee4276ee8a Mon Sep 17 00:00:00 2001 From: dexhorthy Date: Sun, 10 May 2026 16:43:36 -0700 Subject: [PATCH 3/6] Sync MCP name edits to executor.jsonc config file Add storedDataToConfigEntry helper to convert stored source data back to config format, then call configFile.upsertSource in updateSource so name changes persist to the config file. --- packages/plugins/mcp/src/sdk/plugin.ts | 68 +++++++++++++++++++++++++- 1 file changed, 67 insertions(+), 1 deletion(-) diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index 4409b250d..8a144a970 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -973,6 +973,64 @@ const toMcpConfigEntry = ( return entry; }; +/** Convert stored source data back to a config entry for config file sync. + * Only plain string values are preserved in the config file; bindings are + * stored in the database. */ +const storedDataToConfigEntry = ( + namespace: string, + sourceName: string, + data: McpStoredSourceData, +): SourceConfig => { + if (data.transport === "stdio") { + const entry: McpStdioConfigEntry = { + kind: "mcp", + transport: "stdio", + name: sourceName, + command: data.command, + args: data.args, + env: data.env, + cwd: data.cwd, + namespace, + }; + return entry; + } + // For remote sources, extract only plain string values from headers/queryParams. + // ConfiguredCredentialBinding values are stored in the database, not the config file. + const extractPlainStrings = ( + values: Record | undefined, + ): Record | undefined => { + if (!values) return undefined; + const plain: Record = {}; + for (const [key, value] of Object.entries(values)) { + if (typeof value === "string") { + plain[key] = value; + } + } + return Object.keys(plain).length > 0 ? plain : undefined; + }; + const entry: McpRemoteConfigEntry = { + kind: "mcp", + transport: "remote", + name: sourceName, + endpoint: data.endpoint, + remoteTransport: data.remoteTransport, + queryParams: extractPlainStrings(data.queryParams), + headers: extractPlainStrings(data.headers), + namespace, + auth: storedAuthToConfig(data.auth), + }; + return entry; +}; + +/** Convert stored auth back to config auth format. */ +const storedAuthToConfig = (auth: McpConnectionAuth): McpAuthConfig | undefined => { + if (auth.kind === "none") return { kind: "none" }; + // Header and OAuth2 auth use slots which reference bindings in the database. + // The config file cannot represent these directly, so we omit them. + // This is a limitation: auth configured via bindings won't appear in the config. + return undefined; +}; + export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { const allowStdio = options?.dangerouslyAllowStdioMCP ?? false; // Per-plugin-instance runtime holder. Captured by closures in @@ -1491,12 +1549,13 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { ...(input.auth !== undefined ? ["auth:"] : []), ]; const replacementTargetScope = targetScope ?? input.credentialTargetScope ?? scope; + const updatedName = input.name?.trim() || existing.name; yield* ctx.transaction( Effect.gen(function* () { yield* ctx.storage.putSource({ namespace, scope, - name: input.name?.trim() || existing.name, + name: updatedName, config: updatedConfig, }); if (affectedPrefixes.length > 0 || directBindings.length > 0) { @@ -1514,6 +1573,13 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { } }), ); + + // After database update, sync to config file + if (configFile) { + yield* configFile + .upsertSource(storedDataToConfigEntry(namespace, updatedName, updatedConfig)) + .pipe(Effect.withSpan("mcp.plugin.config_file.upsert")); + } }).pipe( Effect.withSpan("mcp.plugin.update_source", { attributes: { "mcp.source.namespace": namespace }, From c6a6ef7b02ece9238c5340a40f703521a80ce72a Mon Sep 17 00:00:00 2001 From: dexhorthy Date: Sun, 10 May 2026 17:04:10 -0700 Subject: [PATCH 4/6] Add secret update API for name and value changes Add PATCH endpoint for updating secrets with optional name and value fields. When value is provided, it's stored via the secret provider's set method. Includes tests for name-only, value-only, and combined updates. --- .../src/services/secrets-api.node.test.ts | 116 ++++++++++++++++++ packages/core/api/src/handlers/secrets.ts | 18 ++- packages/core/api/src/secrets/api.ts | 13 ++ packages/core/execution/src/promise.ts | 1 + packages/core/sdk/src/executor.ts | 77 +++++++++++- packages/core/sdk/src/index.ts | 8 +- packages/core/sdk/src/secrets.ts | 18 +++ 7 files changed, 248 insertions(+), 3 deletions(-) diff --git a/apps/cloud/src/services/secrets-api.node.test.ts b/apps/cloud/src/services/secrets-api.node.test.ts index 87cbbe211..787ff8128 100644 --- a/apps/cloud/src/services/secrets-api.node.test.ts +++ b/apps/cloud/src/services/secrets-api.node.test.ts @@ -163,4 +163,120 @@ describe("secrets api (HTTP)", () => { expect(second.find((s) => s.id === id)?.name).toBe("updated"); }), ); + + it.effect("update changes the secret display name", () => + Effect.gen(function* () { + const org = `org_${crypto.randomUUID()}`; + const id = `sec_${crypto.randomUUID().slice(0, 8)}`; + + // Create the secret + yield* asOrg(org, (client) => + client.secrets.set({ + params: { scopeId: ScopeId.make(org) }, + payload: { id: SecretId.make(id), name: "Original Name", value: "secret-value" }, + }), + ); + + // Update the name via PATCH + const updated = yield* asOrg(org, (client) => + client.secrets.update({ + params: { scopeId: ScopeId.make(org), secretId: SecretId.make(id) }, + payload: { name: "Updated Name" }, + }), + ); + expect(updated.id).toBe(id); + expect(updated.name).toBe("Updated Name"); + + // Verify the list reflects the updated name + const list = yield* asOrg(org, (client) => + client.secrets.list({ params: { scopeId: ScopeId.make(org) } }), + ); + expect(list.find((s) => s.id === id)?.name).toBe("Updated Name"); + }), + ); + + it.effect("update on an unknown id returns 404", () => + Effect.gen(function* () { + const org = `org_${crypto.randomUUID()}`; + const missing = `missing_${crypto.randomUUID().slice(0, 8)}`; + + const result = yield* asOrg(org, (client) => + client.secrets + .update({ + params: { scopeId: ScopeId.make(org), secretId: SecretId.make(missing) }, + payload: { name: "New Name" }, + }) + .pipe(Effect.result), + ); + expect(Result.isFailure(result)).toBe(true); + }), + ); + + it.effect("update changes the secret value", () => + Effect.gen(function* () { + const org = `org_${crypto.randomUUID()}`; + const id = `sec_${crypto.randomUUID().slice(0, 8)}`; + + // Create the secret with initial value + yield* asOrg(org, (client) => + client.secrets.set({ + params: { scopeId: ScopeId.make(org) }, + payload: { id: SecretId.make(id), name: "API Key", value: "initial-secret-value" }, + }), + ); + + // Update the value via PATCH + const updated = yield* asOrg(org, (client) => + client.secrets.update({ + params: { scopeId: ScopeId.make(org), secretId: SecretId.make(id) }, + payload: { value: "updated-secret-value" }, + }), + ); + expect(updated.id).toBe(id); + // Name should remain unchanged + expect(updated.name).toBe("API Key"); + + // Verify the secret status is still resolved + const status = yield* asOrg(org, (client) => + client.secrets.status({ + params: { scopeId: ScopeId.make(org), secretId: SecretId.make(id) }, + }), + ); + expect(status.status).toBe("resolved"); + + // Verify the response doesn't contain the secret value + expect(JSON.stringify(updated)).not.toContain("updated-secret-value"); + }), + ); + + it.effect("update changes both name and value", () => + Effect.gen(function* () { + const org = `org_${crypto.randomUUID()}`; + const id = `sec_${crypto.randomUUID().slice(0, 8)}`; + + // Create the secret + yield* asOrg(org, (client) => + client.secrets.set({ + params: { scopeId: ScopeId.make(org) }, + payload: { id: SecretId.make(id), name: "Original Name", value: "original-value" }, + }), + ); + + // Update both name and value via PATCH + const updated = yield* asOrg(org, (client) => + client.secrets.update({ + params: { scopeId: ScopeId.make(org), secretId: SecretId.make(id) }, + payload: { name: "Updated Name", value: "new-value" }, + }), + ); + expect(updated.id).toBe(id); + expect(updated.name).toBe("Updated Name"); + + // Verify the list reflects the updated name + const list = yield* asOrg(org, (client) => + client.secrets.list({ params: { scopeId: ScopeId.make(org) } }), + ); + expect(list.find((s) => s.id === id)?.name).toBe("Updated Name"); + }), + ); }); diff --git a/packages/core/api/src/handlers/secrets.ts b/packages/core/api/src/handlers/secrets.ts index c3acfd1dc..a9f459eea 100644 --- a/packages/core/api/src/handlers/secrets.ts +++ b/packages/core/api/src/handlers/secrets.ts @@ -1,6 +1,6 @@ import { HttpApiBuilder } from "effect/unstable/httpapi"; import { Effect } from "effect"; -import { RemoveSecretInput, SetSecretInput, type SecretRef } from "@executor-js/sdk"; +import { RemoveSecretInput, SetSecretInput, UpdateSecretInput, type SecretRef } from "@executor-js/sdk"; import { ExecutorApi } from "../api"; import { ExecutorService } from "../services"; @@ -60,6 +60,22 @@ export const SecretsHandlers = HttpApiBuilder.group(ExecutorApi, "secrets", (han }), ), ) + .handle("update", ({ params: path, payload }) => + capture( + Effect.gen(function* () { + const executor = yield* ExecutorService; + const ref = yield* executor.secrets.update( + new UpdateSecretInput({ + id: path.secretId, + scope: path.scopeId, + name: payload.name, + value: payload.value, + }), + ); + return refToResponse(ref); + }), + ), + ) .handle("remove", ({ params: path }) => capture( Effect.gen(function* () { diff --git a/packages/core/api/src/secrets/api.ts b/packages/core/api/src/secrets/api.ts index 170cea4de..f1329aeb3 100644 --- a/packages/core/api/src/secrets/api.ts +++ b/packages/core/api/src/secrets/api.ts @@ -43,6 +43,11 @@ const SetSecretPayload = Schema.Struct({ provider: Schema.optional(Schema.String), }); +const UpdateSecretPayload = Schema.Struct({ + name: Schema.optional(Schema.String), + value: Schema.optional(Schema.String), +}); + // --------------------------------------------------------------------------- // Error schemas with HTTP status annotations // --------------------------------------------------------------------------- @@ -86,6 +91,14 @@ export const SecretsApi = HttpApiGroup.make("secrets") error: [InternalError, SecretResolution], }), ) + .add( + HttpApiEndpoint.patch("update", "/scopes/:scopeId/secrets/:secretId", { + params: SecretParams, + payload: UpdateSecretPayload, + success: SecretRefResponse, + error: [InternalError, SecretNotFound], + }), + ) .add( HttpApiEndpoint.delete("remove", "/scopes/:scopeId/secrets/:secretId", { params: SecretParams, diff --git a/packages/core/execution/src/promise.ts b/packages/core/execution/src/promise.ts index 5755b3ecb..785157d1b 100644 --- a/packages/core/execution/src/promise.ts +++ b/packages/core/execution/src/promise.ts @@ -95,6 +95,7 @@ const wrapPromiseExecutor = (pe: PromiseExecutor): EffectExecutor => ({ getAtScope: (id, scope) => fromPromise(() => pe.secrets.getAtScope(id, scope)), status: (id) => fromPromise(() => pe.secrets.status(id)), set: (input) => fromPromise(() => pe.secrets.set(input)), + update: (input) => fromPromise(() => pe.secrets.update(input)), remove: (input) => fromPromise(() => pe.secrets.remove(input)), list: () => fromPromise(() => pe.secrets.list()), listAll: () => fromPromise(() => pe.secrets.listAll()), diff --git a/packages/core/sdk/src/executor.ts b/packages/core/sdk/src/executor.ts index 0fe1c1f60..e38cb78db 100644 --- a/packages/core/sdk/src/executor.ts +++ b/packages/core/sdk/src/executor.ts @@ -75,6 +75,7 @@ import { NoHandlerError, PluginNotLoadedError, SecretInUseError, + SecretNotFoundError, SecretOwnedByConnectionError, SourceRemovalNotAllowedError, ToolBlockedError, @@ -105,7 +106,13 @@ import type { StorageDeps, } from "./plugin"; import type { Scope } from "./scope"; -import { RemoveSecretInput, SecretRef, SetSecretInput, type SecretProvider } from "./secrets"; +import { + RemoveSecretInput, + SecretRef, + SetSecretInput, + UpdateSecretInput, + type SecretProvider, +} from "./secrets"; import { Usage } from "./usages"; import { ToolSchema, @@ -238,6 +245,10 @@ export type Executor = { * or 1password IPC roundtrips on a pre-flight check. */ readonly status: (id: string) => Effect.Effect<"resolved" | "missing", StorageFailure>; readonly set: (input: SetSecretInput) => Effect.Effect; + /** Update a secret's display name and/or value. The provider is immutable. */ + readonly update: ( + input: UpdateSecretInput, + ) => Effect.Effect; /** Delete a bare (non-connection-owned) secret. Connection-owned * secrets are rejected with `SecretOwnedByConnectionError` — use * `connections.remove` instead. Refuses with `SecretInUseError` @@ -1072,6 +1083,69 @@ export const createExecutor = }); }); + const secretsUpdate = ( + input: UpdateSecretInput, + ): Effect.Effect => + Effect.gen(function* () { + // Validate the scope is in the executor's stack. + if (!scopeIds.includes(input.scope)) { + return yield* new StorageError({ + message: + `secrets.update targets scope "${input.scope}" which is not ` + + `in the executor's scope stack [${scopeIds.join(", ")}].`, + cause: undefined, + }); + } + + // Look up the existing secret row. + const row = yield* findSecretRowAtScope({ + secretId: input.id, + scopeId: input.scope, + }); + if (!row) { + return yield* new SecretNotFoundError({ secretId: SecretId.make(input.id) }); + } + + // If a new value is provided, update it in the provider. + if (input.value !== undefined) { + const provider = secretProviders.get(row.provider); + if (!provider) { + return yield* new StorageError({ + message: `Secret provider "${row.provider}" not found`, + cause: undefined, + }); + } + if (!provider.writable || !provider.set) { + return yield* new StorageError({ + message: `Secret provider "${row.provider}" is read-only`, + cause: undefined, + }); + } + yield* provider.set(input.id, input.value, input.scope); + } + + // Compute the new name (fall back to existing if not provided). + const newName = input.name?.trim() || row.name; + + // Update the secret row. + yield* core.update({ + model: "secret", + where: [ + { field: "id", value: input.id }, + { field: "scope_id", value: input.scope }, + ], + update: { name: newName }, + }); + + return new SecretRef({ + id: SecretId.make(row.id), + scopeId: ScopeId.make(row.scope_id), + name: newName, + provider: row.provider, + createdAt: row.created_at, + }); + }); + // Fan out across every plugin that contributes `usagesForSecret`. Each // plugin queries its own normalized columns through its scoped adapter, // so scope filtering is automatic. @@ -3552,6 +3626,7 @@ export const createExecutor = getAtScope: secretsGetAtScope, status: secretsStatus, set: secretsSet, + update: secretsUpdate, remove: secretsRemove, list: secretsList, listAll: secretsListAll, diff --git a/packages/core/sdk/src/index.ts b/packages/core/sdk/src/index.ts index 3e32a1210..9b721f5af 100644 --- a/packages/core/sdk/src/index.ts +++ b/packages/core/sdk/src/index.ts @@ -114,7 +114,13 @@ export { } from "./policies"; // Secrets -export { SecretRef, SetSecretInput, RemoveSecretInput, type SecretProvider } from "./secrets"; +export { + SecretRef, + SetSecretInput, + UpdateSecretInput, + RemoveSecretInput, + type SecretProvider, +} from "./secrets"; export { SecretBackedMap, diff --git a/packages/core/sdk/src/secrets.ts b/packages/core/sdk/src/secrets.ts index 842962e77..2abc90e91 100644 --- a/packages/core/sdk/src/secrets.ts +++ b/packages/core/sdk/src/secrets.ts @@ -101,3 +101,21 @@ export class RemoveSecretInput extends Schema.Class("RemoveSe * the executor's configured scopes. */ targetScope: ScopeId, }) {} + +// --------------------------------------------------------------------------- +// UpdateSecretInput — update display name and/or value of an existing secret. +// The provider is immutable; the secret value is updated in the existing +// provider if a new value is supplied. +// --------------------------------------------------------------------------- + +export class UpdateSecretInput extends Schema.Class("UpdateSecretInput")({ + id: SecretId, + /** Scope id where the secret row lives. Must be one of the executor's + * configured scopes. */ + scope: ScopeId, + /** New display name for the secret. */ + name: Schema.optional(Schema.String), + /** New value for the secret. If provided, the value is updated in the + * provider (encrypted if the provider encrypts). */ + value: Schema.optional(Schema.String), +}) {} From e06fe6d37f42bc69ac448dadd65cf72c07b7b0d5 Mon Sep 17 00:00:00 2001 From: dexhorthy Date: Sun, 10 May 2026 17:51:02 -0700 Subject: [PATCH 5/6] Sync MCP name updates to core source model When editing an MCP source name, the change was only persisted to the mcp_source table but not to the core source table that the sidebar reads from. Added ctx.core.sources.update() call inside the updateSource transaction to keep both data stores in sync. --- packages/plugins/mcp/src/sdk/plugin.ts | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/packages/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts index 8a144a970..e5568ce9a 100644 --- a/packages/plugins/mcp/src/sdk/plugin.ts +++ b/packages/plugins/mcp/src/sdk/plugin.ts @@ -1558,6 +1558,12 @@ export const mcpPlugin = definePlugin((options?: McpPluginOptions) => { name: updatedName, config: updatedConfig, }); + yield* ctx.core.sources.update({ + id: namespace, + scope, + name: updatedName, + url: updatedConfig.endpoint, + }); if (affectedPrefixes.length > 0 || directBindings.length > 0) { yield* ctx.credentialBindings.replaceForSource({ targetScope: ScopeId.make(replacementTargetScope), From dc3794ecafb666913929951e00de9b3aa0d62da6 Mon Sep 17 00:00:00 2001 From: dexhorthy Date: Sun, 10 May 2026 18:12:40 -0700 Subject: [PATCH 6/6] Add secret edit UI and test for source name sync Add EditSecretDialog component with name and value fields, wire it into the secrets page dropdown menu. Add updateSecret mutation atom with optimistic updates. Add test assertion verifying source name updates propagate to the core sources list. --- packages/plugins/mcp/src/sdk/plugin.test.ts | 5 + packages/react/src/api/atoms.tsx | 21 +++ packages/react/src/pages/secrets.tsx | 142 +++++++++++++++++++- 3 files changed, 167 insertions(+), 1 deletion(-) diff --git a/packages/plugins/mcp/src/sdk/plugin.test.ts b/packages/plugins/mcp/src/sdk/plugin.test.ts index 10586683d..1f6abb423 100644 --- a/packages/plugins/mcp/src/sdk/plugin.test.ts +++ b/packages/plugins/mcp/src/sdk/plugin.test.ts @@ -409,6 +409,11 @@ describe("mcpPlugin", () => { expect(orgView?.config.transport).toBe("remote"); if (orgView?.config.transport !== "remote") return; expect(orgView.config.endpoint).toBe("http://127.0.0.1:1/org-mcp"); + + const sources = yield* executor.sources.list(); + const listedSource = sources.find((source) => source.id === "shared"); + expect(listedSource?.name).toBe("User Renamed"); + expect(listedSource?.url).toBe("http://127.0.0.1:1/user-new-mcp"); }), ); diff --git a/packages/react/src/api/atoms.tsx b/packages/react/src/api/atoms.tsx index 124a075b5..39abc4762 100644 --- a/packages/react/src/api/atoms.tsx +++ b/packages/react/src/api/atoms.tsx @@ -120,6 +120,8 @@ export const policiesAtom = (scopeId: ScopeId) => export const setSecret = ExecutorApiClient.mutation("secrets", "set"); +export const updateSecret = ExecutorApiClient.mutation("secrets", "update"); + export const removeSecret = ExecutorApiClient.mutation("secrets", "remove"); export const removeConnection = ExecutorApiClient.mutation("connections", "remove"); @@ -204,6 +206,25 @@ export const secretsOptimisticAtom = Atom.family((scopeId: ScopeId) => Atom.optimistic(secretsAtom(scopeId)), ); +export const updateSecretOptimistic = Atom.family((scopeId: ScopeId) => + secretsOptimisticAtom(scopeId).pipe( + Atom.optimisticFn({ + reducer: (current, arg) => + AsyncResult.map(current, (rows) => + rows.map((r) => + r.id === arg.params.secretId && r.scopeId === arg.params.scopeId + ? { + ...r, + ...(arg.payload.name !== undefined ? { name: arg.payload.name } : {}), + } + : r, + ), + ), + fn: updateSecret, + }), + ), +); + export const removeSecretOptimistic = Atom.family((scopeId: ScopeId) => secretsOptimisticAtom(scopeId).pipe( Atom.optimisticFn({ diff --git a/packages/react/src/pages/secrets.tsx b/packages/react/src/pages/secrets.tsx index a78338943..fb10fca1b 100644 --- a/packages/react/src/pages/secrets.tsx +++ b/packages/react/src/pages/secrets.tsx @@ -5,7 +5,12 @@ import * as Exit from "effect/Exit"; import * as Option from "effect/Option"; import * as Schema from "effect/Schema"; import { toast } from "sonner"; -import { removeSecretOptimistic, secretsOptimisticAtom, secretUsagesAtom } from "../api/atoms"; +import { + removeSecretOptimistic, + secretsOptimisticAtom, + secretUsagesAtom, + updateSecretOptimistic, +} from "../api/atoms"; import { secretWriteKeys } from "../api/reactivity-keys"; import { useSecretProviderPlugins } from "@executor-js/sdk/client"; import { SecretId, SecretInUseError, type ScopeId } from "@executor-js/sdk"; @@ -22,6 +27,8 @@ import { DialogClose, } from "../components/dialog"; import { Button } from "../components/button"; +import { Input } from "../components/input"; +import { Label } from "../components/label"; import { DropdownMenu, DropdownMenuContent, @@ -162,6 +169,116 @@ function SecretUsageFooter(props: { scopeId: ScopeId; secretId: SecretId }) { }); } +// --------------------------------------------------------------------------- +// Edit secret dialog +// --------------------------------------------------------------------------- + +function EditSecretDialog(props: { + secret: { id: string; scopeId: ScopeId; name: string }; + open: boolean; + onOpenChange: (v: boolean) => void; + onSaved: () => void; +}) { + const [name, setName] = useState(props.secret.name); + const [value, setValue] = useState(""); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(null); + const doUpdate = useAtomSet(updateSecretOptimistic(props.secret.scopeId), { + mode: "promiseExit", + }); + + const nameDirty = name.trim() !== props.secret.name; + const valueDirty = value.length > 0; + const dirty = nameDirty || valueDirty; + + const handleSave = async () => { + if (!dirty) return; + setSaving(true); + setError(null); + const payload: { name?: string; value?: string } = {}; + if (nameDirty) payload.name = name.trim(); + if (valueDirty) payload.value = value; + const exit = await doUpdate({ + params: { scopeId: props.secret.scopeId, secretId: SecretId.make(props.secret.id) }, + payload, + reactivityKeys: secretWriteKeys, + }); + if (Exit.isFailure(exit)) { + setError("Failed to update secret"); + setSaving(false); + return; + } + setSaving(false); + props.onSaved(); + props.onOpenChange(false); + }; + + // Reset form state when dialog opens + const handleOpenChange = (open: boolean) => { + if (open) { + setName(props.secret.name); + setValue(""); + setError(null); + } + props.onOpenChange(open); + }; + + return ( + + + + Edit secret + + Update the display name or rotate the secret value. Leave the value field empty to keep + the current value. + + + +
+
+ + setName(e.target.value)} + placeholder="e.g. Cloudflare API Token" + /> +
+
+ + setValue(e.target.value)} + placeholder="Leave empty to keep current value" + /> +

+ Enter a new value to rotate the secret. The current value is not shown for security. +

+
+ {error && ( +
+

{error}

+
+ )} +
+ + + + + + + +
+
+ ); +} + // --------------------------------------------------------------------------- // Secret row // --------------------------------------------------------------------------- @@ -171,6 +288,7 @@ function SecretRow(props: { showProvider: boolean; secret: { id: string; scopeId: ScopeId; name: string; provider?: string }; scopeLabel: string; + onEdit: () => void; onRemove: () => void; }) { const { secret, showProvider } = props; @@ -211,6 +329,9 @@ function SecretRow(props: { + + Edit secret + (null); const scopeId = useScope(); const scopeStack = useScopeStack(); const secrets = useAtomValue(secretsOptimisticAtom(scopeId)); @@ -378,6 +504,9 @@ export function SecretsPage(props: { provider: s.provider ? String(s.provider) : undefined, }} scopeLabel={scopeLabel(s.scopeId)} + onEdit={() => + setEditingSecret({ id: s.id, scopeId: s.scopeId, name: s.name }) + } onRemove={() => handleRemove(s)} /> ), @@ -396,6 +525,17 @@ export function SecretsPage(props: { existingSecretIds={existingSecretIds} scopeId={scopeId} /> + + {editingSecret && ( + { + if (!open) setEditingSecret(null); + }} + onSaved={() => setEditingSecret(null)} + /> + )} );