From 913c84b22b29d149f6602edf48e4ec48851ce169 Mon Sep 17 00:00:00 2001 From: Christian Glassiognon <63924603+heyglassy@users.noreply.github.com> Date: Mon, 20 Apr 2026 11:52:42 -0700 Subject: [PATCH 1/3] feat(ui): enforce unique secret ids client-side Keep secret ID uniqueness enforcement in the web UI only and preserve the existing upsert behavior in the core secrets API. Prefill source and secrets-page creation flows with the first available unique ID, prevent duplicate manual IDs in the shared React flows, and show each saved secret's ID beside its name in the secrets list with subdued monospace styling and a themed hover tooltip for the full value. Add an optional hideArrow escape hatch to the shared tooltip component so the secrets list can keep that tooltip flush with the dark UI. --- packages/react/src/components/tooltip.tsx | 9 ++- packages/react/src/pages/secrets.tsx | 53 ++++++++++++-- .../react/src/plugins/secret-header-auth.tsx | 30 ++++---- packages/react/src/plugins/secret-id.test.ts | 34 +++++++++ packages/react/src/plugins/secret-id.tsx | 71 +++++++++++++++++++ packages/react/vitest.config.ts | 7 ++ 6 files changed, 182 insertions(+), 22 deletions(-) create mode 100644 packages/react/src/plugins/secret-id.test.ts create mode 100644 packages/react/src/plugins/secret-id.tsx create mode 100644 packages/react/vitest.config.ts diff --git a/packages/react/src/components/tooltip.tsx b/packages/react/src/components/tooltip.tsx index 9ce28391e..cc1fabad3 100644 --- a/packages/react/src/components/tooltip.tsx +++ b/packages/react/src/components/tooltip.tsx @@ -28,8 +28,11 @@ function TooltipContent({ className, sideOffset = 0, children, + hideArrow = false, ...props -}: React.ComponentProps) { +}: React.ComponentProps & { + hideArrow?: boolean; +}) { return ( {children} - + {!hideArrow && ( + + )} ); diff --git a/packages/react/src/pages/secrets.tsx b/packages/react/src/pages/secrets.tsx index be9adb754..cfb5522e7 100644 --- a/packages/react/src/pages/secrets.tsx +++ b/packages/react/src/pages/secrets.tsx @@ -5,6 +5,7 @@ import { secretsAtom, setSecret, removeSecret } from "../api/atoms"; import { secretWriteKeys } from "../api/reactivity-keys"; import { useSecretProviderPlugins } from "@executor-js/sdk/client"; import { SecretId } from "@executor-js/sdk"; +import { useUniqueSecretIdInput } from "../plugins/secret-id"; import { useScope } from "../hooks/use-scope"; import { Dialog, @@ -42,6 +43,7 @@ import { CardStackHeader, } from "../components/card-stack"; import { Badge } from "../components/badge"; +import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../components/tooltip"; type SecretStorageOption = { readonly label: string; @@ -63,9 +65,9 @@ function AddSecretDialog(props: { onOpenChange: (v: boolean) => void; description: string; storageOptions: readonly SecretStorageOption[]; + existingSecretIds: readonly string[]; }) { const initialProvider = props.storageOptions[0]?.value ?? "auto"; - const [id, setId] = useState(""); const [name, setName] = useState(""); const [value, setValue] = useState(""); const [provider, setProvider] = useState(initialProvider); @@ -74,9 +76,19 @@ function AddSecretDialog(props: { const scopeId = useScope(); const doSet = useAtomSet(setSecret, { mode: "promise" }); + const { + secretId: id, + duplicateError, + setSecretIdOverride, + resetSecretIdOverride, + } = useUniqueSecretIdInput({ + baseName: name, + existingSecretIds: props.existingSecretIds, + fallbackId: "", + }); const reset = () => { - setId(""); + resetSecretIdOverride(); setName(""); setValue(""); setProvider(initialProvider); @@ -85,7 +97,7 @@ function AddSecretDialog(props: { }; const handleSave = async () => { - if (!id.trim() || !name.trim() || !value.trim()) return; + if (!id.trim() || !name.trim() || !value.trim() || duplicateError) return; setSaving(true); setError(null); try { @@ -136,9 +148,12 @@ function AddSecretDialog(props: { id="secret-id" placeholder="github-token" value={id} - onChange={(e) => setId((e.target as HTMLInputElement).value)} + onChange={(e) => setSecretIdOverride((e.target as HTMLInputElement).value)} className="font-mono text-xs h-9" /> + {duplicateError && ( +

{duplicateError}

+ )}
diff --git a/packages/react/src/plugins/secret-header-auth.tsx b/packages/react/src/plugins/secret-header-auth.tsx index c11dec015..6594ae540 100644 --- a/packages/react/src/plugins/secret-header-auth.tsx +++ b/packages/react/src/plugins/secret-header-auth.tsx @@ -8,6 +8,7 @@ import { SecretId, type ScopeId } from "@executor-js/sdk"; import { Button } from "../components/button"; import { Field, FieldError, FieldGroup, FieldLabel } from "../components/field"; import { Input } from "../components/input"; +import { slugifyForSecretId, useUniqueSecretIdInput } from "./secret-id"; import { SecretPicker, type SecretPickerSecret } from "./secret-picker"; export interface HeaderAuthPreset { @@ -59,24 +60,16 @@ function SecretVisibilityIcon(props: { revealed: boolean }) { ); } -function slugifyForSecretId(input: string): string { - return input - .trim() - .toLowerCase() - .replace(/[^a-z0-9]+/g, "-") - .replace(/^-+|-+$/g, ""); -} - export function InlineCreateSecret(props: { suggestedId: string; suggestedName: string; + existingSecretIds: readonly string[]; onCreated: (secretId: string) => void; onCancel: () => void; targetScope?: ScopeId; writeScope?: ScopeId; }) { const [nameOverride, setNameOverride] = useState(null); - const [idOverride, setIdOverride] = useState(null); const [secretValue, setSecretValue] = useState(""); const [secretRevealed, setSecretRevealed] = useState(false); const [saving, setSaving] = useState(false); @@ -89,10 +82,18 @@ export function InlineCreateSecret(props: { const secretValueInputId = useId(); const secretName = nameOverride ?? props.suggestedName; - const secretId = idOverride ?? (slugifyForSecretId(secretName) || "custom-header"); + const { + secretId, + duplicateError, + setSecretIdOverride, + } = useUniqueSecretIdInput({ + baseName: secretName, + existingSecretIds: props.existingSecretIds, + fallbackId: props.suggestedId || "custom-header", + }); const handleSave = async () => { - if (!secretId.trim() || !secretValue.trim()) return; + if (!secretId.trim() || !secretValue.trim() || duplicateError) return; setSaving(true); setError(null); try { @@ -131,10 +132,11 @@ export function InlineCreateSecret(props: { setIdOverride((e.target as HTMLInputElement).value)} + onChange={(e) => setSecretIdOverride((e.target as HTMLInputElement).value)} placeholder="my-api-token" className="font-mono" /> + {duplicateError && {duplicateError}} @@ -170,7 +172,7 @@ export function InlineCreateSecret(props: { @@ -295,6 +297,7 @@ export function SecretHeaderAuthRow(props: { secret.id)} onCreated={(id) => { onSelectSecret(id); setCreating(false); @@ -413,6 +416,7 @@ export function CreatableSecretPicker(props: { secret.id)} onCreated={(id) => { onSelect(id); setCreating(false); diff --git a/packages/react/src/plugins/secret-id.test.ts b/packages/react/src/plugins/secret-id.test.ts new file mode 100644 index 000000000..4444acce4 --- /dev/null +++ b/packages/react/src/plugins/secret-id.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "vitest"; + +import { + getUniqueSecretId, + isSecretIdTaken, + slugifyForSecretId, +} from "./secret-id"; + +describe("secret id helpers", () => { + it("slugifies display names into secret ids", () => { + expect(slugifyForSecretId("GitHub PAT")).toBe("github-pat"); + expect(slugifyForSecretId(" Client Secret ")).toBe("client-secret"); + }); + + it("returns the base id when it is unused", () => { + expect(getUniqueSecretId("GitHub PAT", ["openai-api-key"])).toBe("github-pat"); + }); + + it("appends a numeric suffix when the base id already exists", () => { + expect(getUniqueSecretId("GitHub PAT", ["github-pat"])).toBe("github-pat-2"); + expect(getUniqueSecretId("GitHub PAT", ["github-pat", "github-pat-2"])).toBe( + "github-pat-3", + ); + }); + + it("matches existing ids exactly after trimming", () => { + expect(isSecretIdTaken("github-pat", [" github-pat "])).toBe(true); + expect(isSecretIdTaken("github-pat", ["github-pat-2"])).toBe(false); + }); + + it("allows an empty fallback for flows that should start blank", () => { + expect(getUniqueSecretId("", [], "")).toBe(""); + }); +}); diff --git a/packages/react/src/plugins/secret-id.tsx b/packages/react/src/plugins/secret-id.tsx new file mode 100644 index 000000000..3be368a89 --- /dev/null +++ b/packages/react/src/plugins/secret-id.tsx @@ -0,0 +1,71 @@ +import { useMemo, useState } from "react"; + +export function slugifyForSecretId(input: string): string { + return input + .trim() + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); +} + +const normalizeSecretId = (secretId: string): string => secretId.trim(); + +export function isSecretIdTaken( + secretId: string, + existingSecretIds: Iterable, +): boolean { + const normalizedId = normalizeSecretId(secretId); + if (!normalizedId) return false; + + for (const existingSecretId of existingSecretIds) { + if (normalizeSecretId(existingSecretId) === normalizedId) { + return true; + } + } + + return false; +} + +export function getUniqueSecretId( + baseName: string, + existingSecretIds: Iterable, + fallbackId = "secret", +): string { + const baseId = slugifyForSecretId(baseName) || fallbackId; + if (!baseId) return ""; + if (!isSecretIdTaken(baseId, existingSecretIds)) return baseId; + + let suffix = 2; + while (isSecretIdTaken(`${baseId}-${suffix}`, existingSecretIds)) { + suffix += 1; + } + return `${baseId}-${suffix}`; +} + +export function useUniqueSecretIdInput(options: { + baseName: string; + existingSecretIds: readonly string[]; + fallbackId?: string; +}) { + const { baseName, existingSecretIds, fallbackId = "secret" } = options; + const [idOverride, setIdOverride] = useState(null); + + const suggestedId = useMemo( + () => getUniqueSecretId(baseName, existingSecretIds, fallbackId), + [baseName, existingSecretIds, fallbackId], + ); + const secretId = idOverride ?? suggestedId; + const duplicateError = useMemo( + () => (isSecretIdTaken(secretId, existingSecretIds) ? "Secret ID already exists" : null), + [secretId, existingSecretIds], + ); + + return { + secretId, + suggestedId, + duplicateError, + hasManualOverride: idOverride !== null, + setSecretIdOverride: (value: string) => setIdOverride(value), + resetSecretIdOverride: () => setIdOverride(null), + }; +} diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts new file mode 100644 index 000000000..324c1ee66 --- /dev/null +++ b/packages/react/vitest.config.ts @@ -0,0 +1,7 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + include: ["src/**/*.test.ts", "src/**/*.test.tsx"], + }, +}); From d9cec502ed8220841302d77629e3157e2e0388bc Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 3 May 2026 23:19:28 -0700 Subject: [PATCH 2/3] Lift new-secret form into a SecretForm compound provider The dialog and the inline header-auth flow had two divergent copies of the same form: name + auto-derived id + value + scope + submit. The PR's dup-detection only landed in one of them, and state was strewn across five useStates per surface with manual reset() bookkeeping. - New plugins/secret-form.tsx with state/actions/meta context and Provider + NameField/IdField/ValueField/ProviderField/ErrorBanner/ SubmitButton parts. Submit lifecycle is a discriminated status union instead of saving + error booleans. - AddSecretDialog and InlineCreateSecret now compose the parts; layout is each surface's responsibility, logic isn't. - Remount-on-open in the dialog (key prop) replaces the manual reset. - existingSecretIds memoised in SecretsPage so the array reference is stable when the secrets list is unchanged. - Drop the SecretRow secret-id tooltip + the bespoke `hideArrow` tooltip prop; native title attribute carries the same info without a custom Radix subtree. - Drop the `useUniqueSecretIdInput` hook; pure helpers live in secret-id.tsx and the form provider owns the override state directly. Co-authored-by: Rhys Sullivan --- packages/react/src/components/tooltip.tsx | 9 +- packages/react/src/pages/secrets.tsx | 234 +++-------- packages/react/src/plugins/secret-form.tsx | 366 ++++++++++++++++++ .../react/src/plugins/secret-header-auth.tsx | 179 ++------- packages/react/src/plugins/secret-id.test.ts | 2 +- packages/react/src/plugins/secret-id.tsx | 31 +- 6 files changed, 454 insertions(+), 367 deletions(-) create mode 100644 packages/react/src/plugins/secret-form.tsx diff --git a/packages/react/src/components/tooltip.tsx b/packages/react/src/components/tooltip.tsx index cc1fabad3..9ce28391e 100644 --- a/packages/react/src/components/tooltip.tsx +++ b/packages/react/src/components/tooltip.tsx @@ -28,11 +28,8 @@ function TooltipContent({ className, sideOffset = 0, children, - hideArrow = false, ...props -}: React.ComponentProps & { - hideArrow?: boolean; -}) { +}: React.ComponentProps) { return ( {children} - {!hideArrow && ( - - )} + ); diff --git a/packages/react/src/pages/secrets.tsx b/packages/react/src/pages/secrets.tsx index cfb5522e7..1430e7c1e 100644 --- a/packages/react/src/pages/secrets.tsx +++ b/packages/react/src/pages/secrets.tsx @@ -1,11 +1,11 @@ -import { useState, Suspense } from "react"; +import { useMemo, useState, Suspense } from "react"; import { useAtomValue, useAtomSet } from "@effect/atom-react"; import * as AsyncResult from "effect/unstable/reactivity/AsyncResult"; -import { secretsAtom, setSecret, removeSecret } from "../api/atoms"; +import { secretsAtom, removeSecret } from "../api/atoms"; import { secretWriteKeys } from "../api/reactivity-keys"; import { useSecretProviderPlugins } from "@executor-js/sdk/client"; import { SecretId } from "@executor-js/sdk"; -import { useUniqueSecretIdInput } from "../plugins/secret-id"; +import { SecretForm } from "../plugins/secret-form"; import { useScope } from "../hooks/use-scope"; import { Dialog, @@ -17,21 +17,12 @@ import { DialogClose, } from "../components/dialog"; import { Button } from "../components/button"; -import { Input } from "../components/input"; -import { Label } from "../components/label"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger, } from "../components/dropdown-menu"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "../components/select"; import { CardStack, CardStackContent, @@ -43,7 +34,6 @@ import { CardStackHeader, } from "../components/card-stack"; import { Badge } from "../components/badge"; -import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "../components/tooltip"; type SecretStorageOption = { readonly label: string; @@ -58,6 +48,11 @@ const defaultStorageOptions: readonly SecretStorageOption[] = [ // --------------------------------------------------------------------------- // Add secret dialog +// +// Form state, derived id, dup detection, and submit lifecycle live in +// `` and are shared with the inline create flow in +// secret-header-auth.tsx. Dialog content remounts on each open via `key` so +// state always starts fresh — no manual reset. // --------------------------------------------------------------------------- function AddSecretDialog(props: { @@ -67,65 +62,34 @@ function AddSecretDialog(props: { storageOptions: readonly SecretStorageOption[]; existingSecretIds: readonly string[]; }) { - const initialProvider = props.storageOptions[0]?.value ?? "auto"; - const [name, setName] = useState(""); - const [value, setValue] = useState(""); - const [provider, setProvider] = useState(initialProvider); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - - const scopeId = useScope(); - const doSet = useAtomSet(setSecret, { mode: "promise" }); - const { - secretId: id, - duplicateError, - setSecretIdOverride, - resetSecretIdOverride, - } = useUniqueSecretIdInput({ - baseName: name, - existingSecretIds: props.existingSecretIds, - fallbackId: "", - }); - - const reset = () => { - resetSecretIdOverride(); - setName(""); - setValue(""); - setProvider(initialProvider); - setError(null); - setSaving(false); - }; + return ( + + {props.open && ( + props.onOpenChange(false)} + /> + )} + + ); +} - const handleSave = async () => { - if (!id.trim() || !name.trim() || !value.trim() || duplicateError) return; - setSaving(true); - setError(null); - try { - await doSet({ - params: { scopeId }, - payload: { - id: SecretId.make(id.trim()), - name: name.trim(), - value: value.trim(), - provider: provider === "auto" ? undefined : provider, - }, - reactivityKeys: secretWriteKeys, - }); - reset(); - props.onOpenChange(false); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to save secret"); - setSaving(false); - } - }; +function AddSecretDialogContent(props: { + description: string; + storageOptions: readonly SecretStorageOption[]; + existingSecretIds: readonly string[]; + onClose: () => void; +}) { + const initialProvider = props.storageOptions[0]?.value ?? "auto"; return ( - { - if (!v) reset(); - props.onOpenChange(v); - }} + @@ -137,88 +101,12 @@ function AddSecretDialog(props: {
-
- - setSecretIdOverride((e.target as HTMLInputElement).value)} - className="font-mono text-xs h-9" - /> - {duplicateError && ( -

{duplicateError}

- )} -
-
- - setName((e.target as HTMLInputElement).value)} - className="text-sm h-9" - /> -
-
- -
- - setValue((e.target as HTMLInputElement).value)} - className="font-mono text-xs h-9" - /> -
- -
- {props.storageOptions.length > 1 && ( -
- - -
- )} + +
- - {error && ( -
-

{error}

-
- )} + + +
@@ -227,16 +115,10 @@ function AddSecretDialog(props: { Cancel - + Save secret
-
+
); } @@ -258,22 +140,12 @@ function SecretRow(props: { {secret.name} - - - - - {secret.id} - - - - {secret.id} - - - + + {secret.id} + @@ -324,11 +196,15 @@ export function SecretsPage(props: { const [addOpen, setAddOpen] = useState(false); const scopeId = useScope(); const secrets = useAtomValue(secretsAtom(scopeId)); - const existingSecretIds = AsyncResult.match(secrets, { - onInitial: () => [] as string[], - onFailure: () => [] as string[], - onSuccess: ({ value }) => value.map((secret) => secret.id), - }); + const existingSecretIds = useMemo( + () => + AsyncResult.match(secrets, { + onInitial: () => [] as string[], + onFailure: () => [] as string[], + onSuccess: ({ value }) => value.map((secret) => secret.id), + }), + [secrets], + ); const doRemove = useAtomSet(removeSecret, { mode: "promise" }); const handleRemove = async (secretId: string) => { diff --git a/packages/react/src/plugins/secret-form.tsx b/packages/react/src/plugins/secret-form.tsx new file mode 100644 index 000000000..8fb835966 --- /dev/null +++ b/packages/react/src/plugins/secret-form.tsx @@ -0,0 +1,366 @@ +import { + createContext, + use, + useId, + useMemo, + useState, + type CSSProperties, + type ReactNode, +} from "react"; +import { useAtomSet } from "@effect/atom-react"; + +import { setSecret } from "../api/atoms"; +import { secretWriteKeys } from "../api/reactivity-keys"; +import { useScope } from "../api/scope-context"; +import { SecretId, type ScopeId } from "@executor-js/sdk"; +import { Button, type buttonVariants } from "../components/button"; +import { Field, FieldError, FieldLabel } from "../components/field"; +import { Input } from "../components/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "../components/select"; +import type { VariantProps } from "class-variance-authority"; + +import { + getUniqueSecretId, + isSecretIdTaken, +} from "./secret-id"; + +// --------------------------------------------------------------------------- +// Context +// +// One generic interface — `state`, `actions`, `meta` — that both the modal +// and inline create flows share. Surfaces compose the `SecretForm.*` parts +// they want; provider owns state, derived values, and submit lifecycle. +// --------------------------------------------------------------------------- + +type SubmitStatus = + | { readonly kind: "idle" } + | { readonly kind: "submitting" } + | { readonly kind: "error"; readonly message: string }; + +interface SecretFormState { + readonly name: string; + readonly value: string; + readonly idOverride: string | null; + readonly provider: string; + readonly revealed: boolean; + readonly status: SubmitStatus; +} + +interface SecretFormActions { + readonly setName: (v: string) => void; + readonly setValue: (v: string) => void; + readonly setIdOverride: (v: string) => void; + readonly setProvider: (v: string) => void; + readonly toggleReveal: () => void; + readonly submit: () => Promise; +} + +interface SecretFormMeta { + readonly id: string; + readonly duplicateError: string | null; + readonly canSubmit: boolean; +} + +interface SecretFormContextValue { + readonly state: SecretFormState; + readonly actions: SecretFormActions; + readonly meta: SecretFormMeta; +} + +const SecretFormContext = createContext(null); + +function useSecretForm(): SecretFormContextValue { + const ctx = use(SecretFormContext); + if (!ctx) { + throw new Error("SecretForm parts must be rendered inside "); + } + return ctx; +} + +// --------------------------------------------------------------------------- +// Provider +// --------------------------------------------------------------------------- + +interface SecretFormProviderProps { + readonly existingSecretIds: readonly string[]; + readonly suggestedName?: string; + readonly fallbackId?: string; + readonly initialProvider?: string; + readonly scopeId?: ScopeId; + readonly onCreated: (secretId: string) => void; + readonly children: ReactNode; +} + +function SecretFormProvider(props: SecretFormProviderProps) { + const { + existingSecretIds, + suggestedName = "", + fallbackId = "secret", + initialProvider = "auto", + scopeId: scopeIdProp, + onCreated, + children, + } = props; + + const defaultScope = useScope(); + const scopeId = scopeIdProp ?? defaultScope; + const doSet = useAtomSet(setSecret, { mode: "promise" }); + + const [state, setState] = useState(() => ({ + name: "", + value: "", + idOverride: null, + provider: initialProvider, + revealed: false, + status: { kind: "idle" }, + })); + + const baseName = state.name || suggestedName; + const autoId = useMemo( + () => getUniqueSecretId(baseName, existingSecretIds, fallbackId), + [baseName, existingSecretIds, fallbackId], + ); + const id = state.idOverride ?? autoId; + const duplicateError = + state.idOverride !== null && isSecretIdTaken(state.idOverride, existingSecretIds) + ? "Secret ID already exists" + : null; + + const displayName = state.name.trim() || suggestedName.trim(); + const canSubmit = + id.trim() !== "" && + state.value.trim() !== "" && + duplicateError === null && + state.status.kind !== "submitting"; + + const submit = async () => { + if (!canSubmit) return; + setState((s) => ({ ...s, status: { kind: "submitting" } })); + try { + await doSet({ + params: { scopeId }, + payload: { + id: SecretId.make(id.trim()), + name: displayName || id.trim(), + value: state.value.trim(), + provider: state.provider === "auto" ? undefined : state.provider, + }, + reactivityKeys: secretWriteKeys, + }); + onCreated(id.trim()); + } catch (e) { + setState((s) => ({ + ...s, + status: { + kind: "error", + message: e instanceof Error ? e.message : "Failed to save secret", + }, + })); + } + }; + + const value: SecretFormContextValue = { + state, + actions: { + setName: (v) => setState((s) => ({ ...s, name: v })), + setValue: (v) => setState((s) => ({ ...s, value: v })), + setIdOverride: (v) => setState((s) => ({ ...s, idOverride: v })), + setProvider: (v) => setState((s) => ({ ...s, provider: v })), + toggleReveal: () => setState((s) => ({ ...s, revealed: !s.revealed })), + submit, + }, + meta: { id, duplicateError, canSubmit }, + }; + + return {children}; +} + +// --------------------------------------------------------------------------- +// Parts +// --------------------------------------------------------------------------- + +function NameField(props: { label?: string; placeholder?: string }) { + const { state, actions } = useSecretForm(); + const inputId = useId(); + return ( + + {props.label ?? "Name"} + actions.setName((e.target as HTMLInputElement).value)} + placeholder={props.placeholder ?? "GitHub PAT"} + /> + + ); +} + +function IdField(props: { placeholder?: string }) { + const { actions, meta } = useSecretForm(); + const inputId = useId(); + return ( + + ID + actions.setIdOverride((e.target as HTMLInputElement).value)} + placeholder={props.placeholder ?? "github-token"} + className="font-mono" + /> + {meta.duplicateError && {meta.duplicateError}} + + ); +} + +function ValueField(props: { revealable?: boolean; placeholder?: string }) { + const { state, actions } = useSecretForm(); + const inputId = useId(); + const revealable = props.revealable ?? false; + const errored = state.status.kind === "error"; + + return ( + + Value +
+ actions.setValue((e.target as HTMLInputElement).value)} + placeholder={props.placeholder ?? "ghp_xxxxxxxxxxxxxxxxxxxx"} + className={revealable ? "pr-9 font-mono" : "font-mono"} + style={ + revealable && !state.revealed + ? ({ WebkitTextSecurity: "disc" } as CSSProperties) + : undefined + } + /> + {revealable && ( + + )} +
+ {errored && {state.status.kind === "error" ? state.status.message : ""}} +
+ ); +} + +function ProviderField(props: { options: readonly { label: string; value: string }[] }) { + const { state, actions } = useSecretForm(); + const inputId = useId(); + if (props.options.length <= 1) return null; + return ( + + Storage + + + ); +} + +function ErrorBanner() { + const { state } = useSecretForm(); + if (state.status.kind !== "error") return null; + return ( +
+

{state.status.message}

+
+ ); +} + +type ButtonProps = React.ComponentProps<"button"> & VariantProps; + +function SubmitButton(props: ButtonProps & { children?: ReactNode }) { + const { state, meta, actions } = useSecretForm(); + const { children, disabled, onClick, ...rest } = props; + const submitting = state.status.kind === "submitting"; + return ( + + ); +} + +// --------------------------------------------------------------------------- +// Reveal-eye icon (used by ValueField when `revealable`) +// --------------------------------------------------------------------------- + +function SecretVisibilityIcon(props: { revealed: boolean }) { + return props.revealed ? ( + + + + + + + ) : ( + + + + + ); +} + +// --------------------------------------------------------------------------- +// Compound export +// --------------------------------------------------------------------------- + +export const SecretForm = { + Provider: SecretFormProvider, + NameField, + IdField, + ValueField, + ProviderField, + ErrorBanner, + SubmitButton, +}; + +export type { SecretFormProviderProps }; diff --git a/packages/react/src/plugins/secret-header-auth.tsx b/packages/react/src/plugins/secret-header-auth.tsx index 6594ae540..640548b78 100644 --- a/packages/react/src/plugins/secret-header-auth.tsx +++ b/packages/react/src/plugins/secret-header-auth.tsx @@ -1,14 +1,10 @@ -import { useId, useState, type CSSProperties } from "react"; -import { useAtomSet } from "@effect/atom-react"; +import { useId, useState } from "react"; -import { setSecret } from "../api/atoms"; -import { secretWriteKeys } from "../api/reactivity-keys"; -import { useScope } from "../api/scope-context"; -import { SecretId, type ScopeId } from "@executor-js/sdk"; +import { type ScopeId } from "@executor-js/sdk"; import { Button } from "../components/button"; -import { Field, FieldError, FieldGroup, FieldLabel } from "../components/field"; +import { Field, FieldGroup, FieldLabel } from "../components/field"; import { Input } from "../components/input"; -import { slugifyForSecretId, useUniqueSecretIdInput } from "./secret-id"; +import { SecretForm } from "./secret-form"; import { SecretPicker, type SecretPickerSecret } from "./secret-picker"; export interface HeaderAuthPreset { @@ -28,156 +24,40 @@ export const defaultHeaderAuthPresets: readonly HeaderAuthPreset[] = [ { key: "custom", label: "Custom", name: "" }, ]; -function SecretVisibilityIcon(props: { revealed: boolean }) { - return props.revealed ? ( - - - - - - - ) : ( - - - - - ); -} - export function InlineCreateSecret(props: { - suggestedId: string; suggestedName: string; existingSecretIds: readonly string[]; onCreated: (secretId: string) => void; onCancel: () => void; + fallbackId?: string; targetScope?: ScopeId; writeScope?: ScopeId; }) { - const [nameOverride, setNameOverride] = useState(null); - const [secretValue, setSecretValue] = useState(""); - const [secretRevealed, setSecretRevealed] = useState(false); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - const defaultScope = useScope(); - const scopeId = props.targetScope ?? props.writeScope ?? defaultScope; - const doSet = useAtomSet(setSecret, { mode: "promise" }); - const secretIdInputId = useId(); - const secretNameInputId = useId(); - const secretValueInputId = useId(); - - const secretName = nameOverride ?? props.suggestedName; - const { - secretId, - duplicateError, - setSecretIdOverride, - } = useUniqueSecretIdInput({ - baseName: secretName, - existingSecretIds: props.existingSecretIds, - fallbackId: props.suggestedId || "custom-header", - }); - - const handleSave = async () => { - if (!secretId.trim() || !secretValue.trim() || duplicateError) return; - setSaving(true); - setError(null); - try { - await doSet({ - params: { scopeId }, - payload: { - id: SecretId.make(secretId.trim()), - name: secretName.trim() || secretId.trim(), - value: secretValue.trim(), - }, - reactivityKeys: secretWriteKeys, - }); - props.onCreated(secretId.trim()); - } catch (e) { - setError(e instanceof Error ? e.message : "Failed to save secret"); - setSaving(false); - } - }; - return ( -
-

New secret

- -
- - Label - setNameOverride((e.target as HTMLInputElement).value)} - placeholder="API Token" - /> - - - ID - setSecretIdOverride((e.target as HTMLInputElement).value)} - placeholder="my-api-token" - className="font-mono" - /> - {duplicateError && {duplicateError}} - -
- - Value -
- setSecretValue((e.target as HTMLInputElement).value)} - placeholder="paste your token or key…" - className="pr-9 font-mono" - style={secretRevealed ? undefined : ({ WebkitTextSecurity: "disc" } as CSSProperties)} - /> - + +
+

New secret

+ +
+ +
- {error && {error}} - -
-
- - + + +
+ + Create and use +
-
+
); } @@ -290,12 +170,10 @@ export function SecretHeaderAuthRow(props: { const isCustom = presetKey === "custom" || presetKey === undefined; const headerLabel = name.trim() || "Custom Header"; const suggestedName = [sourceName?.trim(), headerLabel].filter(Boolean).join(" "); - const suggestedId = slugifyForSecretId(suggestedName) || "custom-header"; if (creating) { return ( secret.id)} onCreated={(id) => { @@ -409,14 +287,13 @@ export function CreatableSecretPicker(props: { const [creating, setCreating] = useState(false); const suggestedName = [sourceName?.trim(), secretLabel].filter(Boolean).join(" "); - const suggestedId = suggestedIdProp?.trim() || slugifyForSecretId(suggestedName) || "secret"; if (creating) { return ( secret.id)} + fallbackId={suggestedIdProp?.trim() || "secret"} onCreated={(id) => { onSelect(id); setCreating(false); diff --git a/packages/react/src/plugins/secret-id.test.ts b/packages/react/src/plugins/secret-id.test.ts index 4444acce4..0b6fe858b 100644 --- a/packages/react/src/plugins/secret-id.test.ts +++ b/packages/react/src/plugins/secret-id.test.ts @@ -1,4 +1,4 @@ -import { describe, expect, it } from "vitest"; +import { describe, expect, it } from "@effect/vitest"; import { getUniqueSecretId, diff --git a/packages/react/src/plugins/secret-id.tsx b/packages/react/src/plugins/secret-id.tsx index 3be368a89..1b104279a 100644 --- a/packages/react/src/plugins/secret-id.tsx +++ b/packages/react/src/plugins/secret-id.tsx @@ -1,4 +1,5 @@ -import { useMemo, useState } from "react"; +// Pure helpers shared by `SecretForm` (compound form for new-secret flows) +// and the reuse tests. UI state lives in `secret-form.tsx`. export function slugifyForSecretId(input: string): string { return input @@ -41,31 +42,3 @@ export function getUniqueSecretId( } return `${baseId}-${suffix}`; } - -export function useUniqueSecretIdInput(options: { - baseName: string; - existingSecretIds: readonly string[]; - fallbackId?: string; -}) { - const { baseName, existingSecretIds, fallbackId = "secret" } = options; - const [idOverride, setIdOverride] = useState(null); - - const suggestedId = useMemo( - () => getUniqueSecretId(baseName, existingSecretIds, fallbackId), - [baseName, existingSecretIds, fallbackId], - ); - const secretId = idOverride ?? suggestedId; - const duplicateError = useMemo( - () => (isSecretIdTaken(secretId, existingSecretIds) ? "Secret ID already exists" : null), - [secretId, existingSecretIds], - ); - - return { - secretId, - suggestedId, - duplicateError, - hasManualOverride: idOverride !== null, - setSecretIdOverride: (value: string) => setIdOverride(value), - resetSecretIdOverride: () => setIdOverride(null), - }; -} From ae2410be70e0ee711e4eb2a7faf6e77d76cc17ff Mon Sep 17 00:00:00 2001 From: Rhys Sullivan <39114868+RhysSullivan@users.noreply.github.com> Date: Sun, 3 May 2026 23:24:10 -0700 Subject: [PATCH 3/3] Move + New secret to top of SecretPicker dropdown Co-authored-by: Rhys Sullivan --- packages/react/src/plugins/secret-picker.tsx | 40 ++++++++++---------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/packages/react/src/plugins/secret-picker.tsx b/packages/react/src/plugins/secret-picker.tsx index 651544592..8154f96b3 100644 --- a/packages/react/src/plugins/secret-picker.tsx +++ b/packages/react/src/plugins/secret-picker.tsx @@ -35,7 +35,7 @@ export function SecretPicker(props: { readonly onSelect: (secretId: string) => void; readonly secrets: readonly SecretPickerSecret[]; readonly placeholder?: string; - /** When provided, renders a "+ New secret" row at the bottom of the dropdown. */ + /** When provided, renders a "+ New secret" row at the top of the dropdown. */ readonly onCreateNew?: () => void; }) { const { value, onSelect, secrets, placeholder = "Search secrets…", onCreateNew } = props; @@ -98,6 +98,25 @@ export function SecretPicker(props: { No secrets found + {onCreateNew && ( + <> + + { + onCreateNew(); + setOpen(false); + setQuery(""); + }} + className="text-muted-foreground data-[selected=true]:text-foreground" + > + + New secret + + + {secrets.length > 0 && } + + )} {groups.map(([label, items]) => { const lowerQuery = query.toLowerCase(); const filtered = lowerQuery @@ -126,25 +145,6 @@ export function SecretPicker(props: { ); })} - {onCreateNew && ( - <> - {secrets.length > 0 && } - - { - onCreateNew(); - setOpen(false); - setQuery(""); - }} - className="text-muted-foreground data-[selected=true]:text-foreground" - > - - New secret - - - - )}