-
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"],
+ },
+});