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/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}
+
+
+ );
+ })}
+
+
),
});
}
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),
+}) {}
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/plugins/mcp/src/sdk/plugin.ts b/packages/plugins/mcp/src/sdk/plugin.ts
index 07ba4d1f0..e5568ce9a 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
@@ -1395,7 +1453,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(
@@ -1491,14 +1549,21 @@ 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,
});
+ 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),
@@ -1514,6 +1579,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 },
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 (
+
+ );
+}
+
// ---------------------------------------------------------------------------
// 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)}
+ />
+ )}
);