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 ( + + + + 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)} + /> + )} );