diff --git a/packages/react/src/pages/secrets.tsx b/packages/react/src/pages/secrets.tsx index be9adb75..1430e7c1 100644 --- a/packages/react/src/pages/secrets.tsx +++ b/packages/react/src/pages/secrets.tsx @@ -1,10 +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 { SecretForm } from "../plugins/secret-form"; import { useScope } from "../hooks/use-scope"; import { Dialog, @@ -16,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, @@ -56,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: { @@ -63,57 +60,36 @@ 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); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - - const scopeId = useScope(); - const doSet = useAtomSet(setSecret, { mode: "promise" }); - - const reset = () => { - setId(""); - setName(""); - setValue(""); - setProvider(initialProvider); - setError(null); - setSaving(false); - }; + return ( + + {props.open && ( + props.onOpenChange(false)} + /> + )} + + ); +} - const handleSave = async () => { - if (!id.trim() || !name.trim() || !value.trim()) 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); - }} + @@ -125,85 +101,12 @@ function AddSecretDialog(props: {
-
- - setId((e.target as HTMLInputElement).value)} - className="font-mono text-xs h-9" - /> -
-
- - 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}

-
- )} + + +
@@ -212,16 +115,10 @@ function AddSecretDialog(props: { Cancel - + Save secret
-
+
); } @@ -239,8 +136,16 @@ function SecretRow(props: { return ( - - {secret.name} + + + {secret.name} + + + {secret.id} + @@ -291,6 +196,15 @@ export function SecretsPage(props: { const [addOpen, setAddOpen] = useState(false); const scopeId = useScope(); const secrets = useAtomValue(secretsAtom(scopeId)); + 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) => { @@ -413,6 +327,7 @@ export function SecretsPage(props: { onOpenChange={setAddOpen} description={addSecretDescription} storageOptions={storageOptions} + existingSecretIds={existingSecretIds} /> diff --git a/packages/react/src/plugins/secret-form.tsx b/packages/react/src/plugins/secret-form.tsx new file mode 100644 index 00000000..8fb83596 --- /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 c11dec01..640548b7 100644 --- a/packages/react/src/plugins/secret-header-auth.tsx +++ b/packages/react/src/plugins/secret-header-auth.tsx @@ -1,13 +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 { SecretForm } from "./secret-form"; import { SecretPicker, type SecretPickerSecret } from "./secret-picker"; export interface HeaderAuthPreset { @@ -27,155 +24,40 @@ export const defaultHeaderAuthPresets: readonly HeaderAuthPreset[] = [ { key: "custom", label: "Custom", name: "" }, ]; -function SecretVisibilityIcon(props: { revealed: boolean }) { - return props.revealed ? ( - - - - - - - ) : ( - - - - - ); -} - -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; + fallbackId?: string; 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); - 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 = idOverride ?? (slugifyForSecretId(secretName) || "custom-header"); - - const handleSave = async () => { - if (!secretId.trim() || !secretValue.trim()) 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 - setIdOverride((e.target as HTMLInputElement).value)} - placeholder="my-api-token" - className="font-mono" - /> - -
- - 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 +
-
+
); } @@ -288,13 +170,12 @@ 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) => { onSelectSecret(id); setCreating(false); @@ -406,13 +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 new file mode 100644 index 00000000..0b6fe858 --- /dev/null +++ b/packages/react/src/plugins/secret-id.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from "@effect/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 00000000..1b104279 --- /dev/null +++ b/packages/react/src/plugins/secret-id.tsx @@ -0,0 +1,44 @@ +// 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 + .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}`; +} diff --git a/packages/react/src/plugins/secret-picker.tsx b/packages/react/src/plugins/secret-picker.tsx index 65154459..8154f96b 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 - - - - )} diff --git a/packages/react/vitest.config.ts b/packages/react/vitest.config.ts new file mode 100644 index 00000000..324c1ee6 --- /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"], + }, +});